diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index d7814849c6..227f59ad8a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "swashbuckle.aspnetcore.cli": { - "version": "7.3.2", + "version": "9.0.4", "commands": ["swagger"] }, "dotnet-ef": { diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5399bed391..6db4905fec 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,17 +4,18 @@ # # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -## Docker files have shared ownership ## -**/Dockerfile -**/*.Dockerfile -**/.dockerignore -**/entrypoint.sh +## Docker-related files +**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre +**/*.Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre +**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre +**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre +**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre ## 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/_move_edd_db_scripts.yml .github/workflows/release.yml # Database Operations for database changes @@ -33,6 +34,9 @@ util/SqliteMigrations/** @bitwarden/dept-dbops # Shared util projects util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev +# UIF +src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project + # Auth team **/Auth @bitwarden/team-auth-dev bitwarden_license/src/Sso @bitwarden/team-auth-dev @@ -47,11 +51,7 @@ src/Core/IdentityServer @bitwarden/team-auth-dev **/Tools @bitwarden/team-tools-dev # Dirt (Data Insights & Reporting) team -src/Api/Controllers/Dirt @bitwarden/team-data-insights-and-reporting-dev -src/Core/Dirt @bitwarden/team-data-insights-and-reporting-dev -src/Infrastructure.Dapper/Dirt @bitwarden/team-data-insights-and-reporting-dev -test/Api.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev -test/Core.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev +**/Dirt @bitwarden/team-data-insights-and-reporting-dev # Vault team **/Vault @bitwarden/team-vault-dev @@ -93,6 +93,8 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev **/.dockerignore @bitwarden/team-platform-dev **/Dockerfile @bitwarden/team-platform-dev **/entrypoint.sh @bitwarden/team-platform-dev +# The PushType enum is expected to be editted by anyone without need for Platform review +src/Core/Platform/Push/PushType.cs # Multiple owners - DO NOT REMOVE (BRE) **/packages.lock.json diff --git a/.github/ISSUE_TEMPLATE/bw-unified.yml b/.github/ISSUE_TEMPLATE/bw-unified.yml index c1284f1839..240b1faa72 100644 --- a/.github/ISSUE_TEMPLATE/bw-unified.yml +++ b/.github/ISSUE_TEMPLATE/bw-unified.yml @@ -1,4 +1,3 @@ -name: Bitwarden Unified Bug Report name: Bitwarden Unified Deployment Bug Report description: File a bug report labels: [bug, bw-unified-deploy] diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 5c1b259539..5c01832c06 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -9,18 +9,6 @@ "nuget", ], packageRules: [ - { - // Group all release-related workflows for GitHub Actions together for BRE. - groupName: "github-action", - matchManagers: ["github-actions"], - matchFileNames: [ - ".github/workflows/publish.yml", - ".github/workflows/release.yml" - ], - commitMessagePrefix: "[deps] BRE:", - reviewers: ["team:dept-bre"], - addLabels: ["hold"], - }, { groupName: "dockerfile minor", matchManagers: ["dockerfile"], @@ -35,6 +23,7 @@ groupName: "github-action minor", matchManagers: ["github-actions"], matchUpdateTypes: ["minor"], + addLabels: ["hold"], }, { // For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates. @@ -95,7 +84,6 @@ "Serilog.AspNetCore", "Serilog.Extensions.Logging", "Serilog.Extensions.Logging.File", - "Serilog.Sinks.AzureCosmosDB", "Serilog.Sinks.SyslogMessages", "Stripe.net", "Swashbuckle.AspNetCore", diff --git a/.github/workflows/_move_finalization_db_scripts.yml b/.github/workflows/_move_edd_db_scripts.yml similarity index 53% rename from .github/workflows/_move_finalization_db_scripts.yml rename to .github/workflows/_move_edd_db_scripts.yml index d897875394..b38a3e0dff 100644 --- a/.github/workflows/_move_finalization_db_scripts.yml +++ b/.github/workflows/_move_edd_db_scripts.yml @@ -1,5 +1,5 @@ -name: _move_finalization_db_scripts -run-name: Move finalization database scripts +name: _move_edd_db_scripts +run-name: Move EDD database scripts on: workflow_call: @@ -12,14 +12,20 @@ jobs: setup: name: Setup runs-on: ubuntu-22.04 + permissions: + contents: read + id-token: write outputs: migration_filename_prefix: ${{ steps.prefix.outputs.prefix }} - copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }} + copy_edd_scripts: ${{ steps.check-script-existence.outputs.copy_edd_scripts }} + steps: - name: Log in to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -28,6 +34,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Check out branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -37,22 +46,27 @@ jobs: id: prefix run: echo "prefix=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT - - name: Check if any files in DB finalization directory - id: check-finalization-scripts-existence + - name: Check if any files in DB transition or finalization directories + id: check-script-existence run: | - if [ -f util/Migrator/DbScripts_finalization/* ]; then - echo "copy_finalization_scripts=true" >> $GITHUB_OUTPUT + if [ -f util/Migrator/DbScripts_transition/* -o -f util/Migrator/DbScripts_finalization/* ]; then + echo "copy_edd_scripts=true" >> $GITHUB_OUTPUT else - echo "copy_finalization_scripts=false" >> $GITHUB_OUTPUT + echo "copy_edd_scripts=false" >> $GITHUB_OUTPUT fi - move-finalization-db-scripts: - name: Move finalization database scripts + move-scripts: + name: Move scripts runs-on: ubuntu-22.04 needs: setup - if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }} + permissions: + contents: write + pull-requests: write + id-token: write + actions: read + if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }} steps: - - name: Checkout + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 @@ -61,41 +75,70 @@ jobs: id: branch_name env: PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }} - run: echo "branch_name=move_finalization_db_scripts_$PREFIX" >> $GITHUB_OUTPUT + run: echo "branch_name=move_edd_db_scripts_$PREFIX" >> $GITHUB_OUTPUT - name: "Create branch" env: BRANCH: ${{ steps.branch_name.outputs.branch_name }} run: git switch -c $BRANCH - - name: Move DbScripts_finalization + - name: Move scripts and finalization database schema id: move-files env: PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }} run: | - src_dir="util/Migrator/DbScripts_finalization" + # scripts + moved_files="Migration scripts moved:\n\n" + + src_dirs="util/Migrator/DbScripts_transition,util/Migrator/DbScripts_finalization" dest_dir="util/Migrator/DbScripts" i=0 - moved_files="" - for file in "$src_dir"/*; do - filenumber=$(printf "%02d" $i) + for src_dir in ${src_dirs//,/ }; do + for file in "$src_dir"/*; do + filenumber=$(printf "%02d" $i) - filename=$(basename "$file") - new_filename="${PREFIX}_${filenumber}_${filename}" - dest_file="$dest_dir/$new_filename" + filename=$(basename "$file") + new_filename="${PREFIX}_${filenumber}_${filename}" + dest_file="$dest_dir/$new_filename" - mv "$file" "$dest_file" - moved_files="$moved_files \n $filename -> $new_filename" + # Replace any finalization references due to the move + sed -i -e 's/dbo_finalization/dbo/g' "$file" - i=$((i+1)) + mv "$file" "$dest_file" + moved_files="$moved_files \n $filename -> $new_filename" + + i=$((i+1)) + done done + + # schema + moved_files="$moved_files\n\nFinalization scripts moved:\n\n" + + src_dir="src/Sql/dbo_finalization" + dest_dir="src/Sql/dbo" + + # sync finalization schema back to dbo, maintaining structure + rsync -r "$src_dir/" "$dest_dir/" + rm -rf $src_dir/* + + # Replace any finalization references due to the move + find ./src/Sql/dbo -name "*.sql" -type f -exec sed -i \ + -e 's/\[dbo_finalization\]/[dbo]/g' \ + -e 's/dbo_finalization\./dbo./g' {} + + + for file in "$src_dir"/**/*; do + moved_files="$moved_files \n $file" + done + echo "moved_files=$moved_files" >> $GITHUB_OUTPUT - - name: Log in to Azure - production subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -106,8 +149,11 @@ jobs: github-gpg-private-key-passphrase, devops-alerts-slack-webhook-url" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Import GPG keys - uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0 + uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0 with: gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }} passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }} @@ -121,7 +167,7 @@ jobs: git config --local user.name "bitwarden-devops-bot" if [ -n "$(git status --porcelain)" ]; then git add . - git commit -m "Move DbScripts_finalization to DbScripts" -a + git commit -m "Move EDD database scripts" -a git push -u origin ${{ steps.branch_name.outputs.branch_name }} echo "pr_needed=true" >> $GITHUB_OUTPUT else @@ -137,16 +183,16 @@ jobs: BRANCH: ${{ steps.branch_name.outputs.branch_name }} GH_TOKEN: ${{ github.token }} MOVED_FILES: ${{ steps.move-files.outputs.moved_files }} - TITLE: "Move finalization database scripts" + TITLE: "Move EDD database scripts" run: | PR_URL=$(gh pr create --title "$TITLE" \ --base "main" \ --head "$BRANCH" \ --label "automated pr" \ --body " - ## Automated movement of DbScripts_finalization to DbScripts + Automated movement of EDD database scripts. - ## Files moved: + Files moved: $(echo -e "$MOVED_FILES") ") echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT @@ -157,5 +203,5 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }} with: - message: "Created PR for moving DbScripts_finalization to DbScripts: ${{ steps.create-pr.outputs.pr_url }}" + message: "Created PR for moving EDD database scripts: ${{ steps.create-pr.outputs.pr_url }}" status: ${{ job.status }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e7d4a83fed..fe82f9fbe6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ on: types: [opened, synchronize] workflow_call: inputs: {} - + permissions: contents: read @@ -30,7 +30,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 - name: Verify format run: dotnet format --verify-no-changes @@ -95,10 +95,8 @@ jobs: steps: - name: Check secrets id: check-secrets - env: - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} run: | - has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }} + has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT - name: Check out repo @@ -119,10 +117,10 @@ jobs: fi - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: cache: "npm" cache-dependency-path: "**/package-lock.json" @@ -168,25 +166,22 @@ jobs: ########## Set up Docker ########## - name: Set up QEMU emulators - uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 ########## ACRs ########## - - name: Log in to Azure - production subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to ACR - production subscription run: az acr login -n bitwardenprod - - 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 @@ -242,7 +237,7 @@ jobs: - name: Build Docker image id: build-artifacts - uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile @@ -257,7 +252,7 @@ jobs: - name: Install Cosign if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0 + uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2 - name: Sign image with Cosign if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' @@ -274,7 +269,7 @@ jobs: - name: Scan Docker image id: container-scan - uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0 + uses: anchore/scan-action@2c901ab7378897c01b8efaa2d0c9bf519cc64b9e # v6.2.0 with: image: ${{ steps.image-tags.outputs.primary_tag }} fail-build: false @@ -287,10 +282,16 @@ jobs: sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + upload: name: Upload runs-on: ubuntu-24.04 needs: build-artifacts + permissions: + id-token: write + actions: read steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -298,12 +299,14 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 - - name: Log in to Azure - production subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to ACR - production subscription run: az acr login -n $_AZ_REGISTRY --only-show-errors @@ -350,6 +353,9 @@ jobs: cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../.. cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../.. + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Upload Docker stub US artifact if: | github.event_name != 'pull_request' @@ -370,62 +376,23 @@ jobs: path: docker-stub-EU.zip if-no-files-found: error - - name: Build Public API Swagger + - name: Build Swagger files run: | - cd ./src/Api - echo "Restore tools" - dotnet tool restore - echo "Publish" - dotnet publish -c "Release" -o obj/build-output/publish - - dotnet swagger tofile --output ../../swagger.json --host https://api.bitwarden.com \ - ./obj/build-output/publish/Api.dll public - cd ../.. - env: - ASPNETCORE_ENVIRONMENT: Production - swaggerGen: "True" - DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2 - GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder" + cd ./dev + pwsh ./generate_openapi_files.ps1 - name: Upload Public API Swagger artifact uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: name: swagger.json - path: swagger.json + path: api.public.json if-no-files-found: error - - name: Build Internal API Swagger - run: | - cd ./src/Api - echo "Restore API tools" - 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 ../.. - env: - ASPNETCORE_ENVIRONMENT: Development - swaggerGen: "True" - DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2 - GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder" - - name: Upload Internal API Swagger artifact uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: name: internal.json - path: internal.json + path: api.json if-no-files-found: error - name: Upload Identity Swagger artifact @@ -458,7 +425,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 - name: Print environment run: | @@ -496,11 +463,15 @@ jobs: runs-on: ubuntu-24.04 needs: - build-artifacts + permissions: + id-token: write steps: - - name: Log in to Azure - CI subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve GitHub PAT secrets id: retrieve-secret-pat @@ -509,8 +480,11 @@ jobs: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Trigger self-host build - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} script: | @@ -530,11 +504,15 @@ jobs: runs-on: ubuntu-22.04 needs: - build-artifacts + permissions: + id-token: write steps: - - name: Log in to Azure - CI subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve GitHub PAT secrets id: retrieve-secret-pat @@ -543,8 +521,11 @@ jobs: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Trigger k8s deploy - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} script: | @@ -572,7 +553,9 @@ jobs: project: server pull_request_number: ${{ github.event.number || 0 }} secrets: inherit - permissions: read-all + permissions: + contents: read + id-token: write check-failures: name: Check for failures @@ -585,6 +568,8 @@ jobs: - build-mssqlmigratorutility - self-host-build - trigger-k8s-deploy + permissions: + id-token: write steps: - name: Check if any job failed if: | @@ -593,11 +578,12 @@ jobs: && contains(needs.*.result, 'failure') run: exit 1 - - name: Log in to Azure - CI subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 - if: failure() + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -607,6 +593,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Notify Slack on failure uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0 if: failure() diff --git a/.github/workflows/build_target.yml b/.github/workflows/build_target.yml index d825721a7d..20c9cb8ef0 100644 --- a/.github/workflows/build_target.yml +++ b/.github/workflows/build_target.yml @@ -14,6 +14,8 @@ jobs: check-run: name: Check PR run uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + permissions: + contents: read run-workflow: name: Run Build on PR Target @@ -21,3 +23,9 @@ jobs: if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} uses: ./.github/workflows/build.yml secrets: inherit + + permissions: + contents: read + actions: read + id-token: write + security-events: write diff --git a/.github/workflows/cleanup-after-pr.yml b/.github/workflows/cleanup-after-pr.yml index c36dc4a034..e39bf8ea3a 100644 --- a/.github/workflows/cleanup-after-pr.yml +++ b/.github/workflows/cleanup-after-pr.yml @@ -11,11 +11,15 @@ jobs: build-docker: name: Remove branch-specific Docker images runs-on: ubuntu-22.04 + permissions: + id-token: write steps: - - name: Log in to Azure - production subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to Azure ACR run: az acr login -n $_AZ_REGISTRY --only-show-errors @@ -62,3 +66,6 @@ jobs: - name: Log out of Docker run: docker logout + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main diff --git a/.github/workflows/cleanup-rc-branch.yml b/.github/workflows/cleanup-rc-branch.yml index 1ea2eab08a..5c74284423 100644 --- a/.github/workflows/cleanup-rc-branch.yml +++ b/.github/workflows/cleanup-rc-branch.yml @@ -9,11 +9,16 @@ jobs: delete-rc: name: Delete RC Branch runs-on: ubuntu-22.04 + permissions: + contents: write + id-token: write steps: - - name: Login to Azure - CI Subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve bot secrets id: retrieve-bot-secrets @@ -22,6 +27,9 @@ jobs: keyvault: bitwarden-ci secrets: "github-pat-bitwarden-devops-bot-repo-scope" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Checkout main uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index 359e64eb57..75e0c43306 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -1,25 +1,24 @@ name: Collect code references -on: +on: push: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - check-ld-secret: - name: Check for LD secret + check-secret-access: + name: Check for secret access runs-on: ubuntu-22.04 outputs: - available: ${{ steps.check-ld-secret.outputs.available }} - permissions: - contents: read + available: ${{ steps.check-secret-access.outputs.available }} + permissions: {} steps: - name: Check - id: check-ld-secret + id: check-secret-access run: | - if [ "${{ secrets.LD_ACCESS_TOKEN }}" != '' ]; then + if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then echo "available=true" >> $GITHUB_OUTPUT; else echo "available=false" >> $GITHUB_OUTPUT; @@ -28,21 +27,39 @@ jobs: refs: name: Code reference collection runs-on: ubuntu-22.04 - needs: check-ld-secret - if: ${{ needs.check-ld-secret.outputs.available == 'true' }} + needs: check-secret-access + if: ${{ needs.check-secret-access.outputs.available == 'true' }} permissions: contents: read pull-requests: write + id-token: write steps: - name: Check out repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-server + secrets: "LD-ACCESS-TOKEN" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Collect id: collect uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0 with: - accessToken: ${{ secrets.LD_ACCESS_TOKEN }} + accessToken: ${{ steps.get-kv-secrets.outputs.LD-ACCESS-TOKEN }} projKey: default allowTags: true diff --git a/.github/workflows/ephemeral-environment.yml b/.github/workflows/ephemeral-environment.yml index 6dd89536b6..d85fcf2fd4 100644 --- a/.github/workflows/ephemeral-environment.yml +++ b/.github/workflows/ephemeral-environment.yml @@ -4,6 +4,10 @@ on: pull_request: types: [labeled] +permissions: + contents: read + id-token: write + jobs: setup-ephemeral-environment: name: Setup Ephemeral Environment diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml new file mode 100644 index 0000000000..9bc6da89e7 --- /dev/null +++ b/.github/workflows/load-test.yml @@ -0,0 +1,112 @@ +name: Load test + +on: + schedule: + - cron: "0 0 * * 1" # Run every Monday at 00:00 + workflow_dispatch: + inputs: + test-id: + type: string + description: "Identifier label for Datadog metrics" + default: "server-load-test" + k6-test-path: + type: string + description: "Path to load test files" + default: "perf/load/*.js" + k6-flags: + type: string + description: "Additional k6 flags" + api-env-url: + type: string + description: "URL of the API environment" + default: "https://api.qa.bitwarden.pw" + identity-env-url: + type: string + description: "URL of the Identity environment" + default: "https://identity.qa.bitwarden.pw" + +permissions: + contents: read + id-token: write + +env: + # Secret configuration + AZURE_KEY_VAULT_NAME: gh-server + AZURE_KEY_VAULT_SECRETS: DD-API-KEY, K6-CLIENT-ID, K6-AUTH-USER-EMAIL, K6-AUTH-USER-PASSWORD-HASH + # Specify defaults for scheduled runs + TEST_ID: ${{ inputs.test-id || 'server-load-test' }} + K6_TEST_PATH: ${{ inputs.k6-test-path || 'perf/load/*.js' }} + API_ENV_URL: ${{ inputs.api-env-url || 'https://api.qa.bitwarden.pw' }} + IDENTITY_ENV_URL: ${{ inputs.identity-env-url || 'https://identity.qa.bitwarden.pw' }} + +jobs: + run-tests: + name: Run load tests + runs-on: ubuntu-24.04 + steps: + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: ${{ env.AZURE_KEY_VAULT_NAME }} + secrets: ${{ env.AZURE_KEY_VAULT_SECRETS }} + + - name: Log out of Azure + uses: bitwarden/gh-actions/azure-logout@main + + # Datadog agent for collecting OTEL metrics from k6 + - name: Start Datadog agent + run: | + docker run --detach \ + --name datadog-agent \ + -p 4317:4317 \ + -p 5555:5555 \ + -e DD_SITE=us3.datadoghq.com \ + -e DD_API_KEY=${{ steps.get-kv-secrets.outputs.DD-API-KEY }} \ + -e DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 \ + -e DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 \ + -e DD_HEALTH_PORT=5555 \ + -e HOST_PROC=/proc \ + --volume /var/run/docker.sock:/var/run/docker.sock:ro \ + --volume /sys/fs/cgroup/:/host/sys/fs/cgroup:ro \ + --health-cmd "curl -f http://localhost:5555/health || exit 1" \ + --health-interval 10s \ + --health-timeout 5s \ + --health-retries 10 \ + --health-start-period 30s \ + --pid host \ + datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479 + + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up k6 + uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 # v1.1.0 + + - name: Run k6 tests + uses: grafana/run-k6-action@c6b79182b9b666aa4f630f4a6be9158ead62536e # v1.2.0 + continue-on-error: false + env: + K6_OTEL_METRIC_PREFIX: k6_ + K6_OTEL_GRPC_EXPORTER_INSECURE: true + # Load test specific environment variables + API_URL: ${{ env.API_ENV_URL }} + IDENTITY_URL: ${{ env.IDENTITY_ENV_URL }} + CLIENT_ID: ${{ steps.get-kv-secrets.outputs.K6-CLIENT-ID }} + AUTH_USER_EMAIL: ${{ steps.get-kv-secrets.outputs.K6-AUTH-USER-EMAIL }} + AUTH_USER_PASSWORD_HASH: ${{ steps.get-kv-secrets.outputs.K6-AUTH-USER-PASSWORD-HASH }} + with: + flags: >- + --tag test-id=${{ env.TEST_ID }} + -o experimental-opentelemetry + ${{ inputs.k6-flags }} + path: ${{ env.K6_TEST_PATH }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 55220390c4..444c2289d1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,6 +26,9 @@ jobs: setup: name: Setup runs-on: ubuntu-22.04 + permissions: + contents: read + deployments: write outputs: branch-name: ${{ steps.branch.outputs.branch-name }} deployment-id: ${{ steps.deployment.outputs.deployment_id }} @@ -63,6 +66,9 @@ jobs: name: Publish Docker images runs-on: ubuntu-22.04 needs: setup + permissions: + contents: read + id-token: write env: _RELEASE_VERSION: ${{ needs.setup.outputs.release-version }} _BRANCH_NAME: ${{ needs.setup.outputs.branch-name }} @@ -109,10 +115,12 @@ jobs: echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT ########## ACR PROD ########## - - name: Log in to Azure - production subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to Azure ACR run: az acr login -n $_AZ_REGISTRY --only-show-errors @@ -152,12 +160,17 @@ jobs: - name: Log out of Docker run: docker logout + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + update-deployment: name: Update Deployment Status runs-on: ubuntu-22.04 needs: - setup - publish-docker + permissions: + deployments: write if: ${{ always() && inputs.publish_type != 'Dry Run' }} steps: - name: Check if any job failed diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c62587fe39..8bb19b4da1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,7 +86,7 @@ jobs: - name: Create release if: ${{ inputs.release_type != 'Dry Run' }} - uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0 + uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0 with: artifacts: "docker-stub-US.zip, docker-stub-EU.zip, diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index a59bbcfa6c..67e1d8a926 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -22,7 +22,9 @@ on: required: false type: string -permissions: {} +permissions: + pull-requests: write + contents: write jobs: setup: @@ -54,7 +56,27 @@ jobs: - setup outputs: version: ${{ steps.set-final-version-output.outputs.version }} + permissions: + id-token: write + steps: + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Validate version input format if: ${{ inputs.version_number_override != '' }} uses: bitwarden/gh-actions/version-check@main @@ -62,11 +84,11 @@ jobs: version: ${{ inputs.version_number_override }} - name: Generate GH App token - uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 + uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 id: app-token with: - app-id: ${{ secrets.BW_GHAPP_ID }} - private-key: ${{ secrets.BW_GHAPP_KEY }} + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Check out branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -158,13 +180,33 @@ jobs: - setup - bump_version runs-on: ubuntu-24.04 + permissions: + id-token: write + steps: + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Generate GH App token - uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 + uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 id: app-token with: - app-id: ${{ secrets.BW_GHAPP_ID }} - private-key: ${{ secrets.BW_GHAPP_KEY }} + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Check out target ref uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -188,8 +230,13 @@ jobs: git switch --quiet --create $BRANCH_NAME git push --quiet --set-upstream origin $BRANCH_NAME - move_future_db_scripts: - name: Move finalization database scripts + move_edd_db_scripts: + name: Move EDD database scripts needs: cut_branch - uses: ./.github/workflows/_move_finalization_db_scripts.yml + permissions: + actions: read + contents: write + id-token: write + pull-requests: write + uses: ./.github/workflows/_move_edd_db_scripts.yml secrets: inherit diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml new file mode 100644 index 0000000000..b49f5cec8f --- /dev/null +++ b/.github/workflows/review-code.yml @@ -0,0 +1,109 @@ +name: Review code + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: {} + +jobs: + review: + name: Review + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + pull-requests: write + + steps: + - name: Check out repo + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check for Vault team changes + id: check_changes + run: | + # Ensure we have the base branch + git fetch origin ${{ github.base_ref }} + + echo "Comparing changes between origin/${{ github.base_ref }} and HEAD" + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + + if [ -z "$CHANGED_FILES" ]; then + echo "Zero files changed" + echo "vault_team_changes=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Handle variations in spacing and multiple teams + VAULT_PATTERNS=$(grep -E "@bitwarden/team-vault-dev(\s|$)" .github/CODEOWNERS 2>/dev/null | awk '{print $1}') + + if [ -z "$VAULT_PATTERNS" ]; then + echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS" + echo "vault_team_changes=false" >> $GITHUB_OUTPUT + exit 0 + fi + + vault_team_changes=false + for pattern in $VAULT_PATTERNS; do + echo "Checking pattern: $pattern" + + # Handle **/directory patterns + if [[ "$pattern" == "**/"* ]]; then + # Remove the **/ prefix + dir_pattern="${pattern#\*\*/}" + # Check if any file contains this directory in its path + if echo "$CHANGED_FILES" | grep -qE "(^|/)${dir_pattern}(/|$)"; then + vault_team_changes=true + echo "✅ Found files matching pattern: $pattern" + echo "$CHANGED_FILES" | grep -E "(^|/)${dir_pattern}(/|$)" | sed 's/^/ - /' + break + fi + else + # Handle other patterns (shouldn't happen based on your CODEOWNERS) + if echo "$CHANGED_FILES" | grep -q "$pattern"; then + vault_team_changes=true + echo "✅ Found files matching pattern: $pattern" + echo "$CHANGED_FILES" | grep "$pattern" | sed 's/^/ - /' + break + fi + fi + done + + echo "vault_team_changes=$vault_team_changes" >> $GITHUB_OUTPUT + + if [ "$vault_team_changes" = "true" ]; then + echo "" + echo "✅ Vault team changes detected - proceeding with review" + else + echo "" + echo "❌ No Vault team changes detected - skipping review" + fi + + - name: Review with Claude Code + if: steps.check_changes.outputs.vault_team_changes == 'true' + uses: anthropics/claude-code-action@a5528eec7426a4f0c9c1ac96018daa53ebd05bc4 # v1.0.7 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + track_progress: true + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + TITLE: ${{ github.event.pull_request.title }} + BODY: ${{ github.event.pull_request.body }} + AUTHOR: ${{ github.event.pull_request.user.login }} + + Please review this pull request with a focus on: + - Code quality and best practices + - Potential bugs or issues + - Security implications + - Performance considerations + + Note: The PR branch is already checked out in the current working directory. + + Provide detailed feedback using inline comments for specific issues. + + claude_args: | + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index f24a0973fd..f1d9370c29 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -16,83 +16,40 @@ on: branches: - "main" +permissions: {} + jobs: check-run: name: Check PR run uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + permissions: + contents: read sast: - name: SAST scan - runs-on: ubuntu-22.04 + name: Checkmarx + uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main needs: check-run + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: contents: read pull-requests: write security-events: write - - steps: - - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Scan with Checkmarx - uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41 - env: - INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}" - with: - project_name: ${{ github.repository }} - cx_tenant: ${{ secrets.CHECKMARX_TENANT }} - base_uri: https://ast.checkmarx.net/ - cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} - cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} - additional_params: | - --report-format sarif \ - --filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \ - --output-path . ${{ env.INCREMENTAL }} - - - name: Upload Checkmarx results to GitHub - uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 - with: - sarif_file: cx_result.sarif - sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} - ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} + id-token: write quality: - name: Quality scan - runs-on: ubuntu-22.04 + name: Sonar + uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main needs: check-run + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: contents: read pull-requests: write - - steps: - - name: Set up JDK 17 - uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0 - with: - java-version: 17 - distribution: "zulu" - - - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 - - - name: Install SonarCloud scanner - run: dotnet tool install dotnet-sonarscanner -g - - - name: Scan with SonarCloud - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: | - dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" \ - /d:sonar.test.inclusions=test/,bitwarden_license/test/ \ - /d:sonar.exclusions=test/,bitwarden_license/test/ \ - /o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \ - /d:sonar.host.url="https://sonarcloud.io" ${{ contains(github.event_name, 'pull_request') && format('/d:sonar.pullrequest.key={0}', github.event.pull_request.number) || '' }} - dotnet build - dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + id-token: write + with: + sonar-config: "dotnet" diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index 23722e2e8d..6bbc33299f 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -47,7 +47,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 - name: Restore tools run: dotnet tool restore @@ -154,7 +154,7 @@ jobs: run: 'docker logs $(docker ps --quiet --filter "name=mssql")' - name: Report test results - uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0 + uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results @@ -163,7 +163,7 @@ jobs: fail-on-error: true - name: Upload to codecov.io - uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2 + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 - name: Docker Compose down if: always() @@ -179,7 +179,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 - name: Print environment run: | @@ -229,11 +229,27 @@ jobs: - name: Validate XML run: | if grep -q "" "report.xml"; then - echo - echo "Migration files are not in sync with the files in the Sql project. Review to make sure that any stored procedures / other db changes match with the stored procedures in the Sql project." + echo "ERROR: Migration files are not in sync with the SQL project" + echo "" + echo "Check these locations:" + echo " - Migration scripts: util/Migrator/DbScripts/" + echo " - SQL project files: src/Sql/" + echo " - Download 'report.xml' artifact for full details" + echo "" + + # Show actual SQL differences - exclude database setup commands + if [ -s "diff.sql" ]; then + echo "Key SQL differences:" + # Show meaningful schema differences, filtering out database setup noise + grep -E "^(CREATE|DROP|ALTER)" diff.sql | grep -v "ALTER DATABASE" | grep -v "DatabaseName" | head -5 + echo "" + fi + + echo "Common causes: naming differences (underscores, case), missing objects, or definition mismatches" + exit 1 else - echo "Report looks good" + echo "SUCCESS: Database validation passed" fi shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e44d7aa8b8..4eed6df7ab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 - name: Print environment run: | @@ -49,7 +49,7 @@ jobs: run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage" - name: Report test results - uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0 + uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results @@ -58,4 +58,4 @@ jobs: fail-on-error: true - name: Upload to codecov.io - uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2 + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 diff --git a/.gitignore b/.gitignore index 578e095171..2a8441ead3 100644 --- a/.gitignore +++ b/.gitignore @@ -129,7 +129,7 @@ publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings +# TODO: Comment the next line if you want to checkin your web deploy settings # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj @@ -214,6 +214,9 @@ bitwarden_license/src/Sso/wwwroot/assets .idea/* **/**.swp .mono +src/Core/MailTemplates/Mjml/out +NativeMethods.g.cs +util/RustSdk/rust/target src/Admin/Admin.zip src/Api/Api.zip @@ -225,5 +228,8 @@ src/Notifications/Notifications.zip bitwarden_license/src/Portal/Portal.zip bitwarden_license/src/Sso/Sso.zip **/src/**/flags.json -NativeMethods.g.cs -util/RustSdk/rust/target + +# Generated swagger specs +/identity.json +/api.json +/api.public.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..d07bd3f3e1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# Bitwarden Server - Claude Code Configuration + +## Critical Rules + +- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/` which are generated files +- **NEVER** use code regions: If complexity suggests regions, refactor for better readability +- **NEVER** compromise zero-knowledge principles: User vault data must remain encrypted and inaccessible to Bitwarden +- **NEVER** log or expose sensitive data: No PII, passwords, keys, or vault data in logs or error messages +- **ALWAYS** use secure communication channels: Enforce confidentiality, integrity, and authenticity +- **ALWAYS** encrypt sensitive data: All vault data must be encrypted at rest, in transit, and in use +- **ALWAYS** prioritize cryptographic integrity and data protection +- **ALWAYS** add unit tests (with mocking) for any new feature development + +## Project Context + +- **Architecture**: Feature and team-based organization +- **Framework**: .NET 8.0, ASP.NET Core +- **Database**: SQL Server primary, EF Core supports PostgreSQL, MySQL/MariaDB, SQLite +- **Testing**: xUnit, NSubstitute +- **Container**: Docker, Docker Compose, Kubernetes/Helm deployable + +## Project Structure + +- **Source Code**: `/src/` - Services and core infrastructure +- **Tests**: `/test/` - Test logic aligning with the source structure, albeit with a `.Test` suffix +- **Utilities**: `/util/` - Migration tools, seeders, and setup scripts +- **Dev Tools**: `/dev/` - Local development helpers +- **Configuration**: `appsettings.{Environment}.json`, `/dev/secrets.json` for local development + +## Security Requirements + +- **Compliance**: SOC 2 Type II, SOC 3, HIPAA, ISO 27001, GDPR, CCPA +- **Principles**: Zero-knowledge, end-to-end encryption, secure defaults +- **Validation**: Input sanitization, parameterized queries, rate limiting +- **Logging**: Structured logs, no PII/sensitive data in logs + +## Common Commands + +- **Build**: `dotnet build` +- **Test**: `dotnet test` +- **Run locally**: `dotnet run --project src/Api` +- **Database update**: `pwsh dev/migrate.ps1` +- **Generate OpenAPI**: `pwsh dev/generate_openapi_files.ps1` + +## Code Review Checklist + +- Security impact assessed +- xUnit tests added / updated +- Performance impact considered +- Error handling implemented +- Breaking changes documented +- CI passes: build, test, lint +- Feature flags considered for new features +- CODEOWNERS file respected + +### Key Architectural Decisions + +- Use .NET nullable reference types (ADR 0024) +- TryAdd dependency injection pattern (ADR 0026) +- Authorization patterns (ADR 0022) +- OpenTelemetry for observability (ADR 0020) +- Log to standard output (ADR 0021) + +## References + +- [Server architecture](https://contributing.bitwarden.com/architecture/server/) +- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/) +- [Contributing guidelines](https://contributing.bitwarden.com/contributing/) +- [Setup guide](https://contributing.bitwarden.com/getting-started/server/guide/) +- [Code style](https://contributing.bitwarden.com/contributing/code-style/) +- [Bitwarden security whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/) +- [Bitwarden security definitions](https://contributing.bitwarden.com/architecture/security/definitions) diff --git a/Directory.Build.props b/Directory.Build.props index 6a1a305e84..76f35e297e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,62 +3,40 @@ net8.0 - 2025.6.2 + 2025.10.0 Bit.$(MSBuildProjectName) enable false - + true annotations - - + enable true - + - + 17.8.0 - + 2.6.6 - + 2.5.6 - + 6.0.0 - + 5.1.0 - + 4.18.1 - + 4.18.1 - + - + diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 9c88501bb6..d2fc61166e 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -134,6 +134,7 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -343,6 +344,10 @@ Global {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.Build.0 = Debug|Any CPU {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.ActiveCfg = Release|Any CPU {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.Build.0 = Release|Any CPU + {AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -398,6 +403,7 @@ Global {9A612EBA-1C0E-42B8-982B-62F0EE81000A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} + {AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 4af0e12e64..9ade2d660a 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -1,4 +1,6 @@ -using Bit.Core; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; @@ -134,25 +136,13 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }] }; - var setNonUSBusinessUseToReverseCharge = _featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); - - if (setNonUSBusinessUseToReverseCharge) - { - subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; - } - else if (customer.HasRecognizedTaxLocation()) - { - subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = customer.Address.Country == "US" || - customer.TaxIds.Any() - }; - } + subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); organization.GatewaySubscriptionId = subscription.Id; organization.Status = OrganizationStatusType.Created; + organization.Enabled = true; await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0); } diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index 3c75be756a..aaf0050b63 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; @@ -9,7 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; @@ -87,7 +90,7 @@ public class ProviderService : IProviderService _providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand; } - public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) + public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress) { var owner = await _userService.GetUserByIdAsync(ownerUserId); if (owner == null) @@ -112,24 +115,7 @@ public class ProviderService : IProviderService throw new BadRequestException("Invalid owner."); } - if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode)) - { - throw new BadRequestException("Both address and postal code are required to set up your provider."); - } - - var requireProviderPaymentMethodDuringSetup = - _featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup); - - if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource is not - { - Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, - Token: not null and not "" - }) - { - throw new BadRequestException("A payment method is required to set up your provider."); - } - - var customer = await _providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress); provider.GatewayCustomerId = customer.Id; var subscription = await _providerBillingService.SetupSubscription(provider); provider.GatewaySubscriptionId = subscription.Id; @@ -149,7 +135,15 @@ public class ProviderService : IProviderService throw new ArgumentException("Cannot create provider this way."); } + var existingProvider = await _providerRepository.GetByIdAsync(provider.Id); + var enabledStatusChanged = existingProvider != null && existingProvider.Enabled != provider.Enabled; + await _providerRepository.ReplaceAsync(provider); + + if (enabledStatusChanged && (provider.Type == ProviderType.Msp || provider.Type == ProviderType.BusinessUnit)) + { + await UpdateClientOrganizationsEnabledStatusAsync(provider.Id, provider.Enabled); + } } public async Task> InviteUserAsync(ProviderUserInvite invite) @@ -725,4 +719,20 @@ public class ProviderService : IProviderService throw new BadRequestException($"Unsupported provider type {providerType}."); } } + + private async Task UpdateClientOrganizationsEnabledStatusAsync(Guid providerId, bool enabled) + { + var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId); + + foreach (var providerOrganization in providerOrganizations) + { + var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); + if (organization != null && organization.Enabled != enabled) + { + organization.Enabled = enabled; + await _organizationRepository.ReplaceAsync(organization); + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + } + } } diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs index eea40577ad..5fff607f79 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs @@ -1,4 +1,7 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using Bit.Core.Billing.Providers.Entities; using CsvHelper.Configuration.Attributes; diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs new file mode 100644 index 0000000000..cc77797307 --- /dev/null +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs @@ -0,0 +1,107 @@ +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Providers.Models; +using Bit.Core.Billing.Providers.Queries; +using Bit.Core.Billing.Services; +using Bit.Core.Context; +using Bit.Core.Services; +using Stripe; +using Stripe.Tax; + +namespace Bit.Commercial.Core.Billing.Providers.Queries; + +using static Bit.Core.Constants; +using static StripeConstants; +using SuspensionWarning = ProviderWarnings.SuspensionWarning; +using TaxIdWarning = ProviderWarnings.TaxIdWarning; + +public class GetProviderWarningsQuery( + ICurrentContext currentContext, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService) : IGetProviderWarningsQuery +{ + public async Task Run(Provider provider) + { + var warnings = new ProviderWarnings(); + + var subscription = + await subscriberService.GetSubscription(provider, + new SubscriptionGetOptions { Expand = ["customer.tax_ids"] }); + + if (subscription == null) + { + return warnings; + } + + warnings.Suspension = GetSuspensionWarning(provider, subscription); + + warnings.TaxId = await GetTaxIdWarningAsync(provider, subscription.Customer); + + return warnings; + } + + private SuspensionWarning? GetSuspensionWarning( + Provider provider, + Subscription subscription) + { + if (provider.Enabled) + { + return null; + } + + return subscription.Status switch + { + SubscriptionStatus.Unpaid => currentContext.ProviderProviderAdmin(provider.Id) + ? new SuspensionWarning { Resolution = "add_payment_method", SubscriptionCancelsAt = subscription.CancelAt } + : new SuspensionWarning { Resolution = "contact_administrator" }, + _ => new SuspensionWarning { Resolution = "contact_support" } + }; + } + + private async Task GetTaxIdWarningAsync( + Provider provider, + Customer customer) + { + if (customer.Address?.Country == CountryAbbreviations.UnitedStates) + { + return null; + } + + if (!currentContext.ProviderProviderAdmin(provider.Id)) + { + return null; + } + + // TODO: Potentially DRY this out with the GetOrganizationWarningsQuery + + // Get active and scheduled registrations + var registrations = (await Task.WhenAll( + stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }), + stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled }))) + .SelectMany(registrations => registrations.Data); + + // Find the matching registration for the customer + var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address?.Country); + + // If we're not registered in their country, we don't need a warning + if (registration == null) + { + return null; + } + + var taxId = customer.TaxIds.FirstOrDefault(); + + return taxId switch + { + // Customer's tax ID is missing + null => new TaxIdWarning { Type = "tax_id_missing" }, + // Not sure if this case is valid, but Stripe says this property is nullable + not null when taxId.Verification == null => null, + // Customer's tax ID is pending verification + not null when taxId.Verification.Status == TaxIdVerificationStatus.Pending => new TaxIdWarning { Type = "tax_id_pending_verification" }, + // Customer's tax ID failed verification + not null when taxId.Verification.Status == TaxIdVerificationStatus.Unverified => new TaxIdWarning { Type = "tax_id_failed_verification" }, + _ => null + }; + } +} diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index 2b337fb4bb..c9851eb403 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -1,4 +1,7 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using Bit.Commercial.Core.Billing.Providers.Models; using Bit.Core; using Bit.Core.AdminConsole.Entities; @@ -11,6 +14,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; @@ -18,10 +22,8 @@ using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -35,10 +37,12 @@ using Subscription = Stripe.Subscription; namespace Bit.Commercial.Core.Billing.Providers.Services; +using static Constants; +using static StripeConstants; + public class ProviderBillingService( IBraintreeGateway braintreeGateway, IEventService eventService, - IFeatureService featureService, IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, @@ -49,8 +53,7 @@ public class ProviderBillingService( IProviderUserRepository providerUserRepository, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService, - ITaxService taxService) + ISubscriberService subscriberService) : IProviderBillingService { public async Task AddExistingOrganization( @@ -59,10 +62,7 @@ public class ProviderBillingService( string key) { await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, - new SubscriptionUpdateOptions - { - CancelAtPeriodEnd = false - }); + new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }); var subscription = await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId, @@ -81,7 +81,7 @@ public class ProviderBillingService( var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now; - if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft) + if (!wasTrialing && subscription.LatestInvoice.Status == InvoiceStatus.Draft) { await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId, new InvoiceFinalizeOptions { AutoAdvance = true }); @@ -182,16 +182,8 @@ public class ProviderBillingService( { Items = [ - new SubscriptionItemOptions - { - Price = newPriceId, - Quantity = oldSubscriptionItem!.Quantity - }, - new SubscriptionItemOptions - { - Id = oldSubscriptionItem.Id, - Deleted = true - } + new SubscriptionItemOptions { Price = newPriceId, Quantity = oldSubscriptionItem!.Quantity }, + new SubscriptionItemOptions { Id = oldSubscriptionItem.Id, Deleted = true } ] }; @@ -200,7 +192,8 @@ public class ProviderBillingService( // Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId) // 1. Retrieve PlanType and PlanName for ProviderPlan // 2. Assign PlanType & PlanName to Organization - var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId); + var providerOrganizations = + await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId); var newPlan = await pricingClient.GetPlanOrThrow(newPlanType); @@ -211,6 +204,7 @@ public class ProviderBillingService( { throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); } + organization.PlanType = newPlanType; organization.Plan = newPlan.Name; await organizationRepository.ReplaceAsync(organization); @@ -226,15 +220,15 @@ public class ProviderBillingService( if (!string.IsNullOrEmpty(organization.GatewayCustomerId)) { - logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, nameof(organization.GatewayCustomerId)); + logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, + nameof(organization.GatewayCustomerId)); return; } - var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions - { - Expand = ["tax", "tax_ids"] - }); + var providerCustomer = + await subscriberService.GetCustomerOrThrow(provider, + new CustomerGetOptions { Expand = ["tax", "tax_ids"] }); var providerTaxId = providerCustomer.TaxIds.FirstOrDefault(); @@ -267,25 +261,18 @@ public class ProviderBillingService( } ] }, - Metadata = new Dictionary - { - { "region", globalSettings.BaseServiceUri.CloudRegion } - }, - TaxIdData = providerTaxId == null ? null : - [ - new CustomerTaxIdDataOptions - { - Type = providerTaxId.Type, - Value = providerTaxId.Value - } - ] + Metadata = new Dictionary { { "region", globalSettings.BaseServiceUri.CloudRegion } }, + TaxIdData = providerTaxId == null + ? null + : + [ + new CustomerTaxIdDataOptions { Type = providerTaxId.Type, Value = providerTaxId.Value } + ] }; - var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); - - if (setNonUSBusinessUseToReverseCharge && providerCustomer.Address is not { Country: "US" }) + if (providerCustomer.Address is not { Country: CountryAbbreviations.UnitedStates }) { - customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; + customerCreateOptions.TaxExempt = TaxExempt.Reverse; } var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); @@ -347,9 +334,9 @@ public class ProviderBillingService( .Where(pair => pair.subscription is { Status: - StripeConstants.SubscriptionStatus.Active or - StripeConstants.SubscriptionStatus.Trialing or - StripeConstants.SubscriptionStatus.PastDue + SubscriptionStatus.Active or + SubscriptionStatus.Trialing or + SubscriptionStatus.PastDue }).ToList(); if (active.Count == 0) @@ -474,35 +461,27 @@ public class ProviderBillingService( // Below the limit to above the limit (currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) || // Above the limit to further above the limit - (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal); + (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && + newlyAssignedSeatTotal > currentlyAssignedSeatTotal); } public async Task SetupCustomer( Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource = null) + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) { - if (taxInfo is not - { - BillingAddressCountry: not null and not "", - BillingAddressPostalCode: not null and not "" - }) - { - logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id); - throw new BillingException(); - } - var options = new CustomerCreateOptions { Address = new AddressOptions { - Country = taxInfo.BillingAddressCountry, - PostalCode = taxInfo.BillingAddressPostalCode, - Line1 = taxInfo.BillingAddressLine1, - Line2 = taxInfo.BillingAddressLine2, - City = taxInfo.BillingAddressCity, - State = taxInfo.BillingAddressState + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode, + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + State = billingAddress.State }, + Coupon = !string.IsNullOrEmpty(provider.DiscountId) ? provider.DiscountId : null, Description = provider.DisplayBusinessName(), Email = provider.BillingEmail, InvoiceSettings = new CustomerInvoiceSettingsOptions @@ -518,112 +497,71 @@ public class ProviderBillingService( } ] }, - Metadata = new Dictionary - { - { "region", globalSettings.BaseServiceUri.CloudRegion } - } + Metadata = new Dictionary { { "region", globalSettings.BaseServiceUri.CloudRegion } }, + TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None }; - var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); - - if (setNonUSBusinessUseToReverseCharge && taxInfo.BillingAddressCountry != "US") + if (billingAddress.TaxId != null) { - options.TaxExempt = StripeConstants.TaxExempt.Reverse; - } - - if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber)) - { - var taxIdType = taxService.GetStripeTaxCode( - taxInfo.BillingAddressCountry, - taxInfo.TaxIdNumber); - - if (taxIdType == null) - { - logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", - taxInfo.BillingAddressCountry, - taxInfo.TaxIdNumber); - - throw new BadRequestException("billingTaxIdTypeInferenceError"); - } - options.TaxIdData = [ - new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber } + new CustomerTaxIdDataOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value } ]; - if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) + if (billingAddress.TaxId.Code == TaxIdType.SpanishNIF) { options.TaxIdData.Add(new CustomerTaxIdDataOptions { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{taxInfo.TaxIdNumber}" + Type = TaxIdType.EUVAT, + Value = $"ES{billingAddress.TaxId.Value}" }); } } - if (!string.IsNullOrEmpty(provider.DiscountId)) - { - options.Coupon = provider.DiscountId; - } - - var requireProviderPaymentMethodDuringSetup = - featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup); - var braintreeCustomerId = ""; - if (requireProviderPaymentMethodDuringSetup) + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (paymentMethod.Type) { - if (tokenizedPaymentSource is not + case TokenizablePaymentMethodType.BankAccount: { - Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, - Token: not null and not "" - }) - { - logger.LogError("Cannot create customer for provider ({ProviderID}) without a payment method", provider.Id); - throw new BillingException(); - } - - var (type, token) = tokenizedPaymentSource; - - // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (type) - { - case PaymentMethodType.BankAccount: - { - var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token })) - .FirstOrDefault(); - - if (setupIntent == null) + var setupIntent = + (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { - logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id); - throw new BillingException(); - } + PaymentMethod = paymentMethod.Token + })) + .FirstOrDefault(); - await setupIntentCache.Set(provider.Id, setupIntent.Id); - break; - } - case PaymentMethodType.Card: + if (setupIntent == null) { - options.PaymentMethod = token; - options.InvoiceSettings.DefaultPaymentMethod = token; - break; + logger.LogError( + "Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", + provider.Id); + throw new BillingException(); } - case PaymentMethodType.PayPal: - { - braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token); - options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; - break; - } - } + + await setupIntentCache.Set(provider.Id, setupIntent.Id); + break; + } + case TokenizablePaymentMethodType.Card: + { + options.PaymentMethod = paymentMethod.Token; + options.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token; + break; + } + case TokenizablePaymentMethodType.PayPal: + { + braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, paymentMethod.Token); + options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; + break; + } } try { return await stripeAdapter.CustomerCreateAsync(options); } - catch (StripeException stripeException) when (stripeException.StripeError?.Code == - StripeConstants.ErrorCodes.TaxIdInvalid) + catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid) { await Revert(); throw new BadRequestException( @@ -637,25 +575,22 @@ public class ProviderBillingService( async Task Revert() { - if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource != null) + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (paymentMethod.Type) { - // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (tokenizedPaymentSource.Type) - { - case PaymentMethodType.BankAccount: - { - var setupIntentId = await setupIntentCache.Get(provider.Id); - await stripeAdapter.SetupIntentCancel(setupIntentId, - new SetupIntentCancelOptions { CancellationReason = "abandoned" }); - await setupIntentCache.Remove(provider.Id); - break; - } - case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): - { - await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); - break; - } - } + case TokenizablePaymentMethodType.BankAccount: + { + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); + await stripeAdapter.SetupIntentCancel(setupIntentId, + new SetupIntentCancelOptions { CancellationReason = "abandoned" }); + await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id); + break; + } + case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): + { + await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); + break; + } } } } @@ -670,9 +605,10 @@ public class ProviderBillingService( var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); - if (providerPlans == null || providerPlans.Count == 0) + if (providerPlans.Count == 0) { - logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", provider.Id); + logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", + provider.Id); throw new BillingException(); } @@ -685,7 +621,9 @@ public class ProviderBillingService( if (!providerPlan.IsConfigured()) { - logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name); + logger.LogError( + "Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", + provider.Id, plan.Name); throw new BillingException(); } @@ -698,23 +636,17 @@ public class ProviderBillingService( }); } - var requireProviderPaymentMethodDuringSetup = - featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup); - - var setupIntentId = await setupIntentCache.Get(provider.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); var setupIntent = !string.IsNullOrEmpty(setupIntentId) - ? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions - { - Expand = ["payment_method"] - }) + ? await stripeAdapter.SetupIntentGet(setupIntentId, + new SetupIntentGetOptions { Expand = ["payment_method"] }) : null; var usePaymentMethod = - requireProviderPaymentMethodDuringSetup && - (!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || - customer.Metadata.ContainsKey(BraintreeCustomerIdKey) || - setupIntent.IsUnverifiedBankAccount()); + !string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) || + customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true || + setupIntent?.IsUnverifiedBankAccount() == true; int? trialPeriodDays = provider.Type switch { @@ -725,35 +657,20 @@ public class ProviderBillingService( var subscriptionCreateOptions = new SubscriptionCreateOptions { - CollectionMethod = usePaymentMethod ? - StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice, + CollectionMethod = + usePaymentMethod + ? CollectionMethod.ChargeAutomatically + : CollectionMethod.SendInvoice, Customer = customer.Id, DaysUntilDue = usePaymentMethod ? null : 30, Items = subscriptionItemOptionsList, - Metadata = new Dictionary - { - { "providerId", provider.Id.ToString() } - }, + Metadata = new Dictionary { { "providerId", provider.Id.ToString() } }, OffSession = true, - ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, - TrialPeriodDays = trialPeriodDays + ProrationBehavior = ProrationBehavior.CreateProrations, + TrialPeriodDays = trialPeriodDays, + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } }; - var setNonUSBusinessUseToReverseCharge = - featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); - - if (setNonUSBusinessUseToReverseCharge) - { - subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; - } - else if (customer.HasRecognizedTaxLocation()) - { - subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = customer.Address.Country == "US" || - customer.TaxIds.Any() - }; - } try { @@ -761,7 +678,7 @@ public class ProviderBillingService( if (subscription is { - Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing + Status: SubscriptionStatus.Active or SubscriptionStatus.Trialing }) { return subscription; @@ -775,9 +692,11 @@ public class ProviderBillingService( throw new BillingException(); } - catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) + catch (StripeException stripeException) when (stripeException.StripeError?.Code == + ErrorCodes.CustomerTaxLocationInvalid) { - throw new BadRequestException("Your location wasn't recognized. Please ensure your country and postal code are valid."); + throw new BadRequestException( + "Your location wasn't recognized. Please ensure your country and postal code are valid."); } } @@ -791,7 +710,7 @@ public class ProviderBillingService( subscriberService.UpdateTaxInformation(provider, taxInformation)); await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, - new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically }); + new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically }); } public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) @@ -891,13 +810,9 @@ public class ProviderBillingService( await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions { - Items = [ - new SubscriptionItemOptions - { - Id = item.Id, - Price = priceId, - Quantity = newlySubscribedSeats - } + Items = + [ + new SubscriptionItemOptions { Id = item.Id, Price = priceId, Quantity = newlySubscribedSeats } ] }); @@ -920,7 +835,8 @@ public class ProviderBillingService( var plan = await pricingClient.GetPlanOrThrow(planType); return providerOrganizations - .Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed) + .Where(providerOrganization => providerOrganization.Plan == plan.Name && + providerOrganization.Status == OrganizationStatusType.Managed) .Sum(providerOrganization => providerOrganization.Seats ?? 0); } diff --git a/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj b/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj index 57babb4043..9209917d1e 100644 --- a/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj +++ b/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj @@ -5,7 +5,7 @@ - + diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Porting/ImportCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Porting/ImportCommand.cs index 9520f6f00f..dc389256a1 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Porting/ImportCommand.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Porting/ImportCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.SecretsManager.Commands.Porting; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.SecretsManager.Commands.Porting; using Bit.Core.SecretsManager.Commands.Porting.Interfaces; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs index d54644e292..9f37c35f78 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs @@ -1,6 +1,9 @@ -using Bit.Core.Context; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Identity; +using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Identity; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Commands.Projects.Interfaces; using Bit.Core.SecretsManager.Entities; diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs index 687291d75a..b73b358925 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs @@ -1,7 +1,13 @@ -using Bit.Core.Repositories; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Repositories; using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; namespace Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts; @@ -10,15 +16,21 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand private readonly IAccessPolicyRepository _accessPolicyRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IServiceAccountRepository _serviceAccountRepository; + private readonly IEventService _eventService; + private readonly ICurrentContext _currentContext; public CreateServiceAccountCommand( IAccessPolicyRepository accessPolicyRepository, IOrganizationUserRepository organizationUserRepository, - IServiceAccountRepository serviceAccountRepository) + IServiceAccountRepository serviceAccountRepository, + IEventService eventService, + ICurrentContext currentContext) { _accessPolicyRepository = accessPolicyRepository; _organizationUserRepository = organizationUserRepository; _serviceAccountRepository = serviceAccountRepository; + _eventService = eventService; + _currentContext = currentContext; } public async Task CreateAsync(ServiceAccount serviceAccount, Guid userId) @@ -35,6 +47,7 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand Write = true, }; await _accessPolicyRepository.CreateManyAsync(new List { accessPolicy }); + await _eventService.LogServiceAccountPeopleEventAsync(user.Id, accessPolicy, EventType.ServiceAccount_UserAdded, _currentContext.IdentityClientType); return createdServiceAccount; } } diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/AccessClientQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/AccessClientQuery.cs index 8847ee293f..87548e5b6c 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/AccessClientQuery.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/AccessClientQuery.cs @@ -1,4 +1,7 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.SecretsManager.Queries.Interfaces; diff --git a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs index 34f49e0ccc..022045e64f 100644 --- a/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Core/Utilities/ServiceCollectionExtensions.cs @@ -1,8 +1,10 @@ using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Services; +using Bit.Commercial.Core.Billing.Providers.Queries; using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Providers.Queries; using Bit.Core.Billing.Providers.Services; using Microsoft.Extensions.DependencyInjection; @@ -17,5 +19,6 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } } diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ProjectRepository.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ProjectRepository.cs index 40ae58aa6f..78d90f9525 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ProjectRepository.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ProjectRepository.cs @@ -28,7 +28,10 @@ public class ProjectRepository : Repository> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType) + public async Task> GetManyByOrganizationIdAsync( + Guid organizationId, + Guid userId, + AccessClientType accessType) { using var scope = ServiceScopeFactory.CreateScope(); var dbContext = GetDatabaseContext(scope); diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretRepository.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretRepository.cs index 14087ddffa..e783e45118 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretRepository.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretRepository.cs @@ -45,6 +45,19 @@ public class SecretRepository : Repository> GetManyTrashedSecretsByIds(IEnumerable ids) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var secrets = await dbContext.Secret + .Where(c => ids.Contains(c.Id) && c.DeletedDate != null) + .Include(c => c.Projects) + .ToListAsync(); + return Mapper.Map>(secrets); + } + } + public async Task> GetManyByOrganizationIdAsync( Guid organizationId, Guid userId, AccessClientType accessType) { @@ -66,10 +79,14 @@ public class SecretRepository : Repository>(secrets); } - public async Task> GetManyDetailsByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType) + public async Task> GetManyDetailsByOrganizationIdAsync( + Guid organizationId, + Guid userId, + AccessClientType accessType) { using var scope = ServiceScopeFactory.CreateScope(); var dbContext = GetDatabaseContext(scope); + var query = dbContext.Secret .Include(c => c.Projects) .Where(c => c.OrganizationId == organizationId && c.DeletedDate == null) diff --git a/bitwarden_license/src/Scim/Context/ScimContext.cs b/bitwarden_license/src/Scim/Context/ScimContext.cs index efcc8dbde3..bb0286b919 100644 --- a/bitwarden_license/src/Scim/Context/ScimContext.cs +++ b/bitwarden_license/src/Scim/Context/ScimContext.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.Enums; diff --git a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs index 6da4001753..e3c290c85f 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs index 77bc62e952..afbfa50bb4 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs @@ -1,9 +1,11 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Scim.Models; using Bit.Scim.Users.Interfaces; using Bit.Scim.Utilities; @@ -19,29 +21,28 @@ namespace Bit.Scim.Controllers.v2; public class UsersController : Controller { private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IOrganizationService _organizationService; private readonly IGetUsersListQuery _getUsersListQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IPatchUserCommand _patchUserCommand; private readonly IPostUserCommand _postUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; + private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand; - public UsersController( - IOrganizationUserRepository organizationUserRepository, - IOrganizationService organizationService, + public UsersController(IOrganizationUserRepository organizationUserRepository, IGetUsersListQuery getUsersListQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, IPatchUserCommand patchUserCommand, IPostUserCommand postUserCommand, - IRestoreOrganizationUserCommand restoreOrganizationUserCommand) + IRestoreOrganizationUserCommand restoreOrganizationUserCommand, + IRevokeOrganizationUserCommand revokeOrganizationUserCommand) { _organizationUserRepository = organizationUserRepository; - _organizationService = organizationService; _getUsersListQuery = getUsersListQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; _patchUserCommand = patchUserCommand; _postUserCommand = postUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; + _revokeOrganizationUserCommand = revokeOrganizationUserCommand; } [HttpGet("{id}")] @@ -98,7 +99,7 @@ public class UsersController : Controller } else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked) { - await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM); + await _revokeOrganizationUserCommand.RevokeUserAsync(orgUser, EventSystemUser.SCIM); } // Have to get full details object for response model diff --git a/bitwarden_license/src/Scim/Dockerfile b/bitwarden_license/src/Scim/Dockerfile index a0c5c88e49..fca3d83572 100644 --- a/bitwarden_license/src/Scim/Dockerfile +++ b/bitwarden_license/src/Scim/Dockerfile @@ -1,7 +1,7 @@ ############################################### # Build stage # ############################################### -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build # Docker buildx supplies the value for this arg ARG TARGETPLATFORM @@ -9,11 +9,11 @@ ARG TARGETPLATFORM # Determine proper runtime value for .NET # We put the value in a file to be read by later layers. RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ - RID=linux-x64 ; \ + RID=linux-musl-x64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ - RID=linux-arm64 ; \ + RID=linux-musl-arm64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ - RID=linux-arm ; \ + RID=linux-musl-arm ; \ fi \ && echo "RID=$RID" > /tmp/rid.txt @@ -37,21 +37,21 @@ RUN . /tmp/rid.txt && dotnet publish \ ############################################### # App stage # ############################################### -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_URLS=http://+:5000 ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - krb5-user \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache curl \ + krb5 \ + icu-libs \ + shadow \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu # Copy app from the build stage WORKDIR /app diff --git a/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs b/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs index 7055736a4c..cc6546700b 100644 --- a/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs +++ b/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Scim.Groups.Interfaces; diff --git a/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs b/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs index ab082fc2a6..c83b2c0493 100644 --- a/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs +++ b/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; diff --git a/bitwarden_license/src/Scim/Models/BaseScimGroupModel.cs b/bitwarden_license/src/Scim/Models/BaseScimGroupModel.cs index 150885fb50..6004c7572a 100644 --- a/bitwarden_license/src/Scim/Models/BaseScimGroupModel.cs +++ b/bitwarden_license/src/Scim/Models/BaseScimGroupModel.cs @@ -1,4 +1,7 @@ -using Bit.Scim.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Scim.Utilities; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/BaseScimModel.cs b/bitwarden_license/src/Scim/Models/BaseScimModel.cs index 8f3adfbe4a..f4e0d9efdb 100644 --- a/bitwarden_license/src/Scim/Models/BaseScimModel.cs +++ b/bitwarden_license/src/Scim/Models/BaseScimModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Scim.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Scim.Models; public abstract class BaseScimModel { diff --git a/bitwarden_license/src/Scim/Models/BaseScimUserModel.cs b/bitwarden_license/src/Scim/Models/BaseScimUserModel.cs index d3c69d574d..eb8ffe88a6 100644 --- a/bitwarden_license/src/Scim/Models/BaseScimUserModel.cs +++ b/bitwarden_license/src/Scim/Models/BaseScimUserModel.cs @@ -1,4 +1,7 @@ -using Bit.Scim.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Scim.Utilities; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/ScimErrorResponseModel.cs b/bitwarden_license/src/Scim/Models/ScimErrorResponseModel.cs index d1dce35ef0..064acc476b 100644 --- a/bitwarden_license/src/Scim/Models/ScimErrorResponseModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimErrorResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Scim.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Scim.Utilities; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/ScimGroupRequestModel.cs b/bitwarden_license/src/Scim/Models/ScimGroupRequestModel.cs index 11bd40c587..3a9c795f58 100644 --- a/bitwarden_license/src/Scim/Models/ScimGroupRequestModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimGroupRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Utilities; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/ScimGroupResponseModel.cs b/bitwarden_license/src/Scim/Models/ScimGroupResponseModel.cs index 697a3d59da..a3d7c2054a 100644 --- a/bitwarden_license/src/Scim/Models/ScimGroupResponseModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimGroupResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/ScimListResponseModel.cs b/bitwarden_license/src/Scim/Models/ScimListResponseModel.cs index 77ab52356c..9f5cc61f97 100644 --- a/bitwarden_license/src/Scim/Models/ScimListResponseModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimListResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Scim.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Scim.Utilities; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/ScimPatchModel.cs b/bitwarden_license/src/Scim/Models/ScimPatchModel.cs index 6707ced85f..5392a18e3c 100644 --- a/bitwarden_license/src/Scim/Models/ScimPatchModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimPatchModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs index 295db790e3..fc4f781e42 100644 --- a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs @@ -1,11 +1,14 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Utilities; -using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; namespace Bit.Scim.Models; @@ -44,7 +47,7 @@ public class ScimUserRequestModel : BaseScimUserModel return new InviteOrganizationUsersRequest( invites: [ - new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: email, externalId: ExternalIdForInvite() ) diff --git a/bitwarden_license/src/Scim/Startup.cs b/bitwarden_license/src/Scim/Startup.cs index 3fac669eda..edbbf34aea 100644 --- a/bitwarden_license/src/Scim/Startup.cs +++ b/bitwarden_license/src/Scim/Startup.cs @@ -8,7 +8,7 @@ using Bit.Core.Utilities; using Bit.Scim.Context; using Bit.Scim.Utilities; using Bit.SharedWeb.Utilities; -using IdentityModel; +using Duende.IdentityModel; using Microsoft.Extensions.DependencyInjection.Extensions; using Stripe; diff --git a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs index 9bcbcbdafc..a734635ebf 100644 --- a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs +++ b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Data.Organizations.OrganizationUsers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Scim.Users.Interfaces; diff --git a/bitwarden_license/src/Scim/Users/Interfaces/IPostUserCommand.cs b/bitwarden_license/src/Scim/Users/Interfaces/IPostUserCommand.cs index 05dd05510c..401754ad10 100644 --- a/bitwarden_license/src/Scim/Users/Interfaces/IPostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/Interfaces/IPostUserCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Data.Organizations.OrganizationUsers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Scim.Models; namespace Bit.Scim.Users.Interfaces; diff --git a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs index 3d7082aacc..6c983611ee 100644 --- a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs @@ -1,8 +1,8 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Scim.Models; using Bit.Scim.Users.Interfaces; @@ -11,20 +11,19 @@ namespace Bit.Scim.Users; public class PatchUserCommand : IPatchUserCommand { private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IOrganizationService _organizationService; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly ILogger _logger; + private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand; - public PatchUserCommand( - IOrganizationUserRepository organizationUserRepository, - IOrganizationService organizationService, + public PatchUserCommand(IOrganizationUserRepository organizationUserRepository, IRestoreOrganizationUserCommand restoreOrganizationUserCommand, - ILogger logger) + ILogger logger, + IRevokeOrganizationUserCommand revokeOrganizationUserCommand) { _organizationUserRepository = organizationUserRepository; - _organizationService = organizationService; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; _logger = logger; + _revokeOrganizationUserCommand = revokeOrganizationUserCommand; } public async Task PatchUserAsync(Guid organizationId, Guid id, ScimPatchModel model) @@ -80,7 +79,7 @@ public class PatchUserCommand : IPatchUserCommand } else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked) { - await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM); + await _revokeOrganizationUserCommand.RevokeUserAsync(orgUser, EventSystemUser.SCIM); return true; } return false; diff --git a/bitwarden_license/src/Scim/Utilities/ApiKeyAuthenticationHandler.cs b/bitwarden_license/src/Scim/Utilities/ApiKeyAuthenticationHandler.cs index 4e7e7ceb7a..6ebffb73cd 100644 --- a/bitwarden_license/src/Scim/Utilities/ApiKeyAuthenticationHandler.cs +++ b/bitwarden_license/src/Scim/Utilities/ApiKeyAuthenticationHandler.cs @@ -3,7 +3,7 @@ using System.Text.Encodings.Web; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Scim.Context; -using IdentityModel; +using Duende.IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; diff --git a/bitwarden_license/src/Scim/entrypoint.sh b/bitwarden_license/src/Scim/entrypoint.sh index 41930504d3..c3ff43e8dc 100644 --- a/bitwarden_license/src/Scim/entrypoint.sh +++ b/bitwarden_license/src/Scim/entrypoint.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # Setup @@ -37,7 +37,7 @@ then mkdir -p /etc/bitwarden/ca-certificates chown -R $USERNAME:$GROUPNAME /etc/bitwarden - if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos fi @@ -46,13 +46,13 @@ else gosu_cmd="" fi -if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then +if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -if [[ $globalSettings__selfHosted == "true" ]]; then - if [[ -z $globalSettings__identityServer__certificateLocation ]]; then +if [ "$globalSettings__selfHosted" = "true" ]; then + if [ -z "$globalSettings__identityServer__certificateLocation" ]; then export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx fi fi diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 12394ff598..98a581e8ca 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -1,5 +1,9 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Core; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; @@ -19,10 +23,10 @@ using Bit.Core.Tokens; using Bit.Core.Utilities; using Bit.Sso.Models; using Bit.Sso.Utilities; +using Duende.IdentityModel; using Duende.IdentityServer; using Duende.IdentityServer.Services; using Duende.IdentityServer.Stores; -using IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -104,36 +108,32 @@ public class AccountController : Controller // Validate domain_hint provided if (string.IsNullOrWhiteSpace(domainHint)) { - return InvalidJson("NoOrganizationIdentifierProvidedError"); + _logger.LogError(new ArgumentException("domainHint is required."), "domainHint not specified."); + return InvalidJson("SsoInvalidIdentifierError"); } // Validate organization exists from domain_hint var organization = await _organizationRepository.GetByIdentifierAsync(domainHint); - if (organization == null) + if (organization is not { UseSso: true }) { - return InvalidJson("OrganizationNotFoundByIdentifierError"); - } - if (!organization.UseSso) - { - return InvalidJson("SsoNotAllowedForOrganizationError"); + _logger.LogError("Organization not configured to use SSO."); + return InvalidJson("SsoInvalidIdentifierError"); } // Validate SsoConfig exists and is Enabled var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint); - if (ssoConfig == null) + if (ssoConfig is not { Enabled: true }) { - return InvalidJson("SsoConfigurationNotFoundForOrganizationError"); - } - if (!ssoConfig.Enabled) - { - return InvalidJson("SsoNotEnabledForOrganizationError"); + _logger.LogError("SsoConfig not enabled."); + return InvalidJson("SsoInvalidIdentifierError"); } // Validate Authentication Scheme exists and is loaded (cache) var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString()); - if (scheme == null || !(scheme is IDynamicAuthenticationScheme dynamicScheme)) + if (scheme is not IDynamicAuthenticationScheme dynamicScheme) { - return InvalidJson("NoSchemeOrHandlerForSsoConfigurationFoundError"); + _logger.LogError("Invalid authentication scheme for organization."); + return InvalidJson("SsoInvalidIdentifierError"); } // Run scheme validation @@ -143,13 +143,8 @@ public class AccountController : Controller } catch (Exception ex) { - var translatedException = _i18nService.GetLocalizedHtmlString(ex.Message); - var errorKey = "InvalidSchemeConfigurationError"; - if (!translatedException.ResourceNotFound) - { - errorKey = ex.Message; - } - return InvalidJson(errorKey, translatedException.ResourceNotFound ? ex : null); + _logger.LogError(ex, "An error occurred while validating SSO dynamic scheme."); + return InvalidJson("SsoInvalidIdentifierError"); } var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds); @@ -159,7 +154,8 @@ public class AccountController : Controller } catch (Exception ex) { - return InvalidJson("PreValidationError", ex); + _logger.LogError(ex, "An error occurred during SSO prevalidation."); + return InvalidJson("SsoInvalidIdentifierError"); } } @@ -251,18 +247,23 @@ public class AccountController : Controller var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}"); _logger.LogDebug("External claims: {@claims}", externalClaims); - // Lookup our user and external provider info + // See if the user has logged in with this SSO provider before and has already been provisioned. + // This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using. var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result); + + // The user has not authenticated with this SSO provider before. + // They could have an existing Bitwarden account in the User table though. if (user == null) { - // This might be where you might initiate a custom workflow for user registration - // in this sample we don't show how that would be done, as our sample implementation - // simply auto-provisions new external user + // If we're manually linking to SSO, the user's external identifier will be passed as query string parameter. var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ? result.Properties.Items["user_identifier"] : null; user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData); } + // Either the user already authenticated with the SSO provider, or we've just provisioned them. + // Either way, we have associated the SSO login with a Bitwarden user. + // We will now sign the Bitwarden user in. if (user != null) { // This allows us to collect any additional claims or properties @@ -342,6 +343,10 @@ public class AccountController : Controller } } + /// + /// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`. + /// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records. + /// private async Task<(User user, string provider, string providerUserId, IEnumerable claims, SsoConfigurationData config)> FindUserFromExternalProviderAsync(AuthenticateResult result) { @@ -399,6 +404,23 @@ public class AccountController : Controller return (user, provider, providerUserId, claims, ssoConfigData); } + /// + /// Provision an SSO-linked Bitwarden user. + /// This handles three different scenarios: + /// 1. Creating an SsoUser link for an existing User and OrganizationUser + /// - User is a member of the organization, but hasn't authenticated with the org's SSO provider before. + /// 2. Creating a new User and a new OrganizationUser, then establishing an SsoUser link + /// - User is joining the organization through JIT provisioning, without a pending invitation + /// 3. Creating a new User for an existing OrganizationUser (created by invitation), then establishing an SsoUser link + /// - User is signing in with a pending invitation. + /// + /// The external identity provider. + /// The external identity provider's user identifier. + /// The claims from the external IdP. + /// The user identifier used for manual SSO linking. + /// The SSO configuration for the organization. + /// The User to sign in. + /// An exception if the user cannot be provisioned as requested. private async Task AutoProvisionUserAsync(string provider, string providerUserId, IEnumerable claims, string userIdentifier, SsoConfigurationData config) { @@ -426,50 +448,15 @@ public class AccountController : Controller } else { - var split = userIdentifier.Split(","); - if (split.Length < 2) - { - throw new Exception(_i18nService.T("InvalidUserIdentifier")); - } - var userId = split[0]; - var token = split[1]; - - var tokenOptions = new TokenOptions(); - - var claimedUser = await _userService.GetUserByIdAsync(userId); - if (claimedUser != null) - { - var tokenIsValid = await _userManager.VerifyUserTokenAsync( - claimedUser, tokenOptions.PasswordResetTokenProvider, TokenPurposes.LinkSso, token); - if (tokenIsValid) - { - existingUser = claimedUser; - } - else - { - throw new Exception(_i18nService.T("UserIdAndTokenMismatch")); - } - } + existingUser = await GetUserFromManualLinkingData(userIdentifier); } - OrganizationUser orgUser = null; - var organization = await _organizationRepository.GetByIdAsync(orgId); - if (organization == null) - { - throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId)); - } + // Try to find the OrganizationUser if it exists. + var (organization, orgUser) = await FindOrganizationUser(existingUser, email, orgId); - // Try to find OrgUser via existing User Id (accepted/confirmed user) - if (existingUser != null) - { - var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id); - orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId); - } - - // If no Org User found by Existing User Id - search all organization users via email - orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email); - - // All Existing User flows handled below + //---------------------------------------------------- + // Scenario 1: We've found the user in the User table + //---------------------------------------------------- if (existingUser != null) { if (existingUser.UsesKeyConnector && @@ -478,20 +465,22 @@ public class AccountController : Controller throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector")); } + // If the user already exists in Bitwarden, we require that the user already be in the org, + // and that they are either Accepted or Confirmed. if (orgUser == null) { // Org User is not created - no invite has been sent throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess")); } - if (orgUser.Status == OrganizationUserStatusType.Invited) - { - // Org User is invited - they must manually accept the invite via email and authenticate with MP - // This allows us to enroll them in MP reset if required - throw new Exception(_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName())); - } + EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(), + allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]); - // Accepted or Confirmed - create SSO link and return; + + // Since we're in the auto-provisioning logic, this means that the user exists, but they have not + // authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them). + // We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed + // with authentication. await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser); return existingUser; } @@ -534,7 +523,9 @@ public class AccountController : Controller emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false; } - // Create user record - all existing user flows are handled above + //-------------------------------------------------- + // Scenarios 2 and 3: We need to register a new user + //-------------------------------------------------- var user = new User { Name = name, @@ -560,7 +551,11 @@ public class AccountController : Controller await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email); } - // Create Org User if null or else update existing Org User + //----------------------------------------------------------------- + // Scenario 2: We also need to create an OrganizationUser + // This means that an invitation was not sent for this user and we + // need to establish their invited status now. + //----------------------------------------------------------------- if (orgUser == null) { orgUser = new OrganizationUser @@ -572,18 +567,107 @@ public class AccountController : Controller }; await _organizationUserRepository.CreateAsync(orgUser); } + //----------------------------------------------------------------- + // Scenario 3: There is already an existing OrganizationUser + // That was established through an invitation. We just need to + // update the UserId now that we have created a User record. + //----------------------------------------------------------------- else { orgUser.UserId = user.Id; await _organizationUserRepository.ReplaceAsync(orgUser); } - // Create sso user record + // Create the SsoUser record to link the user to the SSO provider. await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser); return user; } + private async Task GetUserFromManualLinkingData(string userIdentifier) + { + User user = null; + var split = userIdentifier.Split(","); + if (split.Length < 2) + { + throw new Exception(_i18nService.T("InvalidUserIdentifier")); + } + var userId = split[0]; + var token = split[1]; + + var tokenOptions = new TokenOptions(); + + var claimedUser = await _userService.GetUserByIdAsync(userId); + if (claimedUser != null) + { + var tokenIsValid = await _userManager.VerifyUserTokenAsync( + claimedUser, tokenOptions.PasswordResetTokenProvider, TokenPurposes.LinkSso, token); + if (tokenIsValid) + { + user = claimedUser; + } + else + { + throw new Exception(_i18nService.T("UserIdAndTokenMismatch")); + } + } + return user; + } + + private async Task<(Organization, OrganizationUser)> FindOrganizationUser(User existingUser, string email, Guid orgId) + { + OrganizationUser orgUser = null; + var organization = await _organizationRepository.GetByIdAsync(orgId); + if (organization == null) + { + throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId)); + } + + // Try to find OrgUser via existing User Id. + // This covers any OrganizationUser state after they have accepted an invite. + if (existingUser != null) + { + var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id); + orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId); + } + + // If no Org User found by Existing User Id - search all the organization's users via email. + // This covers users who are Invited but haven't accepted their invite yet. + orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email); + + return (organization, orgUser); + } + + private void EnsureOrgUserStatusAllowed( + OrganizationUserStatusType status, + string organizationDisplayName, + params OrganizationUserStatusType[] allowedStatuses) + { + // if this status is one of the allowed ones, just return + if (allowedStatuses.Contains(status)) + { + return; + } + + // otherwise throw the appropriate exception + switch (status) + { + case OrganizationUserStatusType.Invited: + // Org User is invited – must accept via email first + throw new Exception( + _i18nService.T("AcceptInviteBeforeUsingSSO", organizationDisplayName)); + case OrganizationUserStatusType.Revoked: + // Revoked users may not be (auto)‑provisioned + throw new Exception( + _i18nService.T("OrganizationUserAccessRevoked", organizationDisplayName)); + default: + // anything else is “unknown” + throw new Exception( + _i18nService.T("OrganizationUserUnknownStatus", organizationDisplayName)); + } + } + + private IActionResult InvalidJson(string errorMessageKey, Exception ex = null) { Response.StatusCode = ex == null ? 400 : 500; diff --git a/bitwarden_license/src/Sso/Controllers/HomeController.cs b/bitwarden_license/src/Sso/Controllers/HomeController.cs index 7be9d86215..da30d5106d 100644 --- a/bitwarden_license/src/Sso/Controllers/HomeController.cs +++ b/bitwarden_license/src/Sso/Controllers/HomeController.cs @@ -1,4 +1,7 @@ -using System.Diagnostics; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Diagnostics; using Bit.Sso.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authorization; diff --git a/bitwarden_license/src/Sso/Dockerfile b/bitwarden_license/src/Sso/Dockerfile index d5d012b416..cbd049b9bd 100644 --- a/bitwarden_license/src/Sso/Dockerfile +++ b/bitwarden_license/src/Sso/Dockerfile @@ -1,7 +1,7 @@ ############################################### # Build stage # ############################################### -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build # Docker buildx supplies the value for this arg ARG TARGETPLATFORM @@ -9,11 +9,11 @@ ARG TARGETPLATFORM # Determine proper runtime value for .NET # We put the value in a file to be read by later layers. RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ - RID=linux-x64 ; \ + RID=linux-musl-x64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ - RID=linux-arm64 ; \ + RID=linux-musl-arm64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ - RID=linux-arm ; \ + RID=linux-musl-arm ; \ fi \ && echo "RID=$RID" > /tmp/rid.txt @@ -37,21 +37,21 @@ RUN . /tmp/rid.txt && dotnet publish \ ############################################### # App stage # ############################################### -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_URLS=http://+:5000 ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - krb5-user \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache curl \ + krb5 \ + icu-libs \ + shadow \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu # Copy app from the build stage WORKDIR /app diff --git a/bitwarden_license/src/Sso/Models/ErrorViewModel.cs b/bitwarden_license/src/Sso/Models/ErrorViewModel.cs index 1f6e9735e7..8efb95b09e 100644 --- a/bitwarden_license/src/Sso/Models/ErrorViewModel.cs +++ b/bitwarden_license/src/Sso/Models/ErrorViewModel.cs @@ -1,4 +1,7 @@ -using Duende.IdentityServer.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Duende.IdentityServer.Models; namespace Bit.Sso.Models; diff --git a/bitwarden_license/src/Sso/Models/RedirectViewModel.cs b/bitwarden_license/src/Sso/Models/RedirectViewModel.cs index 9bc294d96c..0e2a642207 100644 --- a/bitwarden_license/src/Sso/Models/RedirectViewModel.cs +++ b/bitwarden_license/src/Sso/Models/RedirectViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Sso.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Sso.Models; public class RedirectViewModel { diff --git a/bitwarden_license/src/Sso/Models/SamlEnvironment.cs b/bitwarden_license/src/Sso/Models/SamlEnvironment.cs index 6de718029a..0a7dbcd44b 100644 --- a/bitwarden_license/src/Sso/Models/SamlEnvironment.cs +++ b/bitwarden_license/src/Sso/Models/SamlEnvironment.cs @@ -1,4 +1,7 @@ -using System.Security.Cryptography.X509Certificates; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Cryptography.X509Certificates; namespace Bit.Sso.Models; diff --git a/bitwarden_license/src/Sso/Sso.csproj b/bitwarden_license/src/Sso/Sso.csproj index 1b6b666ab1..2a1c14ae5a 100644 --- a/bitwarden_license/src/Sso/Sso.csproj +++ b/bitwarden_license/src/Sso/Sso.csproj @@ -10,7 +10,7 @@ - + diff --git a/bitwarden_license/src/Sso/Utilities/ClaimsExtensions.cs b/bitwarden_license/src/Sso/Utilities/ClaimsExtensions.cs index f82614635c..7c34217805 100644 --- a/bitwarden_license/src/Sso/Utilities/ClaimsExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/ClaimsExtensions.cs @@ -1,4 +1,7 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using System.Text.RegularExpressions; namespace Bit.Sso.Utilities; diff --git a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs index 8bde8f84a1..db574e71c5 100644 --- a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs +++ b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs @@ -1,15 +1,19 @@ -using System.Security.Cryptography.X509Certificates; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Cryptography.X509Certificates; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Sso.Models; using Bit.Sso.Utilities; +using Duende.IdentityModel; using Duende.IdentityServer; using Duende.IdentityServer.Infrastructure; -using IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.Options; @@ -413,7 +417,7 @@ public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider SPOptions = spOptions, SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme, SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme, - CookieManager = new IdentityServer.DistributedCacheCookieManager(), + CookieManager = new DistributedCacheCookieManager(), }; options.IdentityProviders.Add(idp); diff --git a/bitwarden_license/src/Sso/Utilities/ExtendedOptionsMonitorCache.cs b/bitwarden_license/src/Sso/Utilities/ExtendedOptionsMonitorCache.cs index 083417f25b..4f95e4bf39 100644 --- a/bitwarden_license/src/Sso/Utilities/ExtendedOptionsMonitorCache.cs +++ b/bitwarden_license/src/Sso/Utilities/ExtendedOptionsMonitorCache.cs @@ -1,4 +1,7 @@ -using System.Collections.Concurrent; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Collections.Concurrent; using Microsoft.Extensions.Options; namespace Bit.Sso.Utilities; diff --git a/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs b/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs index 825ed74dc8..199d8475a6 100644 --- a/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Authentication.OpenIdConnect; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace Bit.Sso.Utilities; diff --git a/bitwarden_license/src/Sso/Utilities/Saml2OptionsExtensions.cs b/bitwarden_license/src/Sso/Utilities/Saml2OptionsExtensions.cs index 46a75ca5c2..55ee63e91a 100644 --- a/bitwarden_license/src/Sso/Utilities/Saml2OptionsExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/Saml2OptionsExtensions.cs @@ -1,4 +1,7 @@ -using System.IO.Compression; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.IO.Compression; using System.Text; using System.Xml; using Sustainsys.Saml2; diff --git a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs index b1c0a55cbe..a51a04f5c8 100644 --- a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs @@ -1,4 +1,7 @@ -using Bit.Core.Business.Sso; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Business.Sso; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; diff --git a/bitwarden_license/src/Sso/entrypoint.sh b/bitwarden_license/src/Sso/entrypoint.sh index c762659fb3..6ae590f18c 100644 --- a/bitwarden_license/src/Sso/entrypoint.sh +++ b/bitwarden_license/src/Sso/entrypoint.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # Setup @@ -37,7 +37,7 @@ then mkdir -p /etc/bitwarden/ca-certificates chown -R $USERNAME:$GROUPNAME /etc/bitwarden - if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos fi @@ -46,13 +46,13 @@ else gosu_cmd="" fi -if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then +if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -if [[ $globalSettings__selfHosted == "true" ]]; then - if [[ -z $globalSettings__identityServer__certificateLocation ]]; then +if [ "$globalSettings__selfHosted" = "true" ]; then + if [ -z "$globalSettings__identityServer__certificateLocation" ]; then export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx fi fi diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index 636d6317a1..aeefbd69d7 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -17,9 +17,9 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.88.0", + "sass": "1.91.0", "sass-loader": "16.0.5", - "webpack": "5.99.8", + "webpack": "5.101.3", "webpack-cli": "5.1.4" } }, @@ -34,18 +34,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -58,20 +54,10 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", "dependencies": { @@ -80,16 +66,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -441,9 +427,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -455,13 +441,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", - "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.10.0" } }, "node_modules/@webassemblyjs/ast": { @@ -687,9 +673,9 @@ "license": "Apache-2.0" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -699,6 +685,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -781,9 +780,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", "dev": true, "funding": [ { @@ -801,8 +800,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -821,9 +820,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "version": "1.0.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", "dev": true, "funding": [ { @@ -975,16 +974,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.155", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", - "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", + "version": "1.5.215", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz", + "integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, "license": "MIT", "dependencies": { @@ -1107,9 +1106,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, "funding": [ { @@ -1241,9 +1240,9 @@ } }, "node_modules/immutable": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", - "integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", "dev": true, "license": "MIT" }, @@ -1528,9 +1527,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", + "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", "dev": true, "license": "MIT" }, @@ -1635,9 +1634,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -1655,7 +1654,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1860,9 +1859,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.88.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz", - "integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==", + "version": "1.91.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", + "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "dev": true, "license": "MIT", "dependencies": { @@ -2061,24 +2060,28 @@ } }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { - "version": "5.39.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", - "integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==", + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -2139,9 +2142,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", "dev": true, "license": "MIT" }, @@ -2198,22 +2201,23 @@ } }, "node_modules/webpack": { - "version": "5.99.8", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", - "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", + "version": "5.101.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", + "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", + "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -2227,7 +2231,7 @@ "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -2317,9 +2321,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "license": "MIT", "engines": { diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index 137f86680c..28f40f0d25 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -16,9 +16,9 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.88.0", + "sass": "1.91.0", "sass-loader": "16.0.5", - "webpack": "5.99.8", + "webpack": "5.101.3", "webpack-cli": "5.1.4" } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index 5be18116c0..9b9c41048b 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -1,5 +1,4 @@ using Bit.Commercial.Core.AdminConsole.Providers; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -263,7 +262,8 @@ public class RemoveOrganizationFromProviderCommandTests org => org.BillingEmail == "a@example.com" && org.GatewaySubscriptionId == "subscription_id" && - org.Status == OrganizationStatusType.Created)); + org.Status == OrganizationStatusType.Created && + org.Enabled == true)); // Verify organization is enabled when new subscription is created await sutProvider.GetDependency().Received(1) .DeleteAsync(providerOrganization); @@ -331,9 +331,6 @@ public class RemoveOrganizationFromProviderCommandTests Id = "subscription_id" }); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); - await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is(options => @@ -354,7 +351,8 @@ public class RemoveOrganizationFromProviderCommandTests org => org.BillingEmail == "a@example.com" && org.GatewaySubscriptionId == "subscription_id" && - org.Status == OrganizationStatusType.Created)); + org.Status == OrganizationStatusType.Created && + org.Enabled == true)); // Verify organization is enabled when new subscription is created await sutProvider.GetDependency().Received(1) .DeleteAsync(providerOrganization); @@ -390,4 +388,62 @@ public class RemoveOrganizationFromProviderCommandTests } } }; + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_DisabledOrganization_ConsolidatedBilling_EnablesOrganization( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + // Arrange: Set up a disabled organization that meets the criteria for consolidated billing + provider.Status = ProviderStatusType.Billable; + providerOrganization.ProviderId = provider.Id; + organization.Status = OrganizationStatusType.Managed; + organization.PlanType = PlanType.TeamsMonthly; + organization.Enabled = false; // Start with a disabled organization + + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + + sutProvider.GetDependency().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); + + sutProvider.GetDependency().HasConfirmedOwnersExceptAsync( + providerOrganization.OrganizationId, + [], + includeProvider: false) + .Returns(true); + + var organizationRepository = sutProvider.GetDependency(); + + organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([ + "owner@example.com" + ]); + + var stripeAdapter = sutProvider.GetDependency(); + + stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(new Customer + { + Id = "customer_id", + Address = new Address + { + Country = "US" + } + }); + + stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(new Subscription + { + Id = "new_subscription_id" + }); + + // Act + await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); + + // Assert: Verify the disabled organization is now enabled + await organizationRepository.Received(1).ReplaceAsync(Arg.Is( + org => + org.Enabled == true && // The previously disabled organization should now be enabled + org.Status == OrganizationStatusType.Created && + org.GatewaySubscriptionId == "new_subscription_id")); + } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index cb8a9e8c69..e61cf5f97e 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -1,15 +1,15 @@ using Bit.Commercial.Core.AdminConsole.Services; using Bit.Commercial.Core.Test.AdminConsole.AutoFixture; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; @@ -41,7 +41,7 @@ public class ProviderServiceTests public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider sutProvider) { var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null)); + () => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null, null)); Assert.Contains("Invalid owner.", exception.Message); } @@ -53,85 +53,12 @@ public class ProviderServiceTests userService.GetUserByIdAsync(user.Id).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null)); + () => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null, null)); Assert.Contains("Invalid token.", exception.Message); } [Theory, BitAutoData] - public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException( - User user, - Provider provider, - string key, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource, - [ProviderUser] ProviderUser providerUser, - SutProvider sutProvider) - { - providerUser.ProviderId = provider.Id; - providerUser.UserId = user.Id; - var userService = sutProvider.GetDependency(); - userService.GetUserByIdAsync(user.Id).Returns(user); - - var providerUserRepository = sutProvider.GetDependency(); - providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); - - var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); - var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); - sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") - .Returns(protector); - - sutProvider.Create(); - - var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - - taxInfo.BillingAddressCountry = null; - - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource)); - - Assert.Equal("Both address and postal code are required to set up your provider.", exception.Message); - } - - [Theory, BitAutoData] - public async Task CompleteSetupAsync_InvalidTokenizedPaymentSource_ThrowsBadRequestException( - User user, - Provider provider, - string key, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource, - [ProviderUser] ProviderUser providerUser, - SutProvider sutProvider) - { - providerUser.ProviderId = provider.Id; - providerUser.UserId = user.Id; - var userService = sutProvider.GetDependency(); - userService.GetUserByIdAsync(user.Id).Returns(user); - - var providerUserRepository = sutProvider.GetDependency(); - providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); - - var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); - var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); - sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") - .Returns(protector); - - sutProvider.Create(); - - var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); - - tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay }; - - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource)); - - Assert.Equal("A payment method is required to set up your provider.", exception.Message); - } - - [Theory, BitAutoData] - public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource, + public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress, [ProviderUser] ProviderUser providerUser, SutProvider sutProvider) { @@ -151,7 +78,7 @@ public class ProviderServiceTests var providerBillingService = sutProvider.GetDependency(); var customer = new Customer { Id = "customer_id" }; - providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer); + providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer); var subscription = new Subscription { Id = "subscription_id" }; providerBillingService.SetupSubscription(provider).Returns(subscription); @@ -160,7 +87,7 @@ public class ProviderServiceTests var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource); + await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, tokenizedPaymentMethod, billingAddress); await sutProvider.GetDependency().Received().UpsertAsync(Arg.Is( p => @@ -188,6 +115,262 @@ public class ProviderServiceTests await sutProvider.Sut.UpdateAsync(provider); } + [Theory, BitAutoData] + public async Task UpdateAsync_ExistingProviderIsNull_DoesNotCallUpdateClientOrganizationsEnabledStatus( + Provider provider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + + providerRepository.GetByIdAsync(provider.Id).Returns((Provider)null); + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EnabledStatusNotChanged_DoesNotCallUpdateClientOrganizationsEnabledStatus( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = provider.Enabled; // Same enabled status + provider.Type = ProviderType.Msp; // Set to a type that would trigger update if status changed + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EnabledStatusChangedButProviderTypeIsReseller_DoesNotCallUpdateClientOrganizationsEnabledStatus( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.Reseller; // Type that should not trigger update + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EnabledStatusChangedAndProviderTypeIsMsp_CallsUpdateClientOrganizationsEnabledStatus( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.Msp; // Type that should trigger update + + // Create test provider organization details + var providerOrganizationDetails = new List + { + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }, + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() } + }; + + // Create test organizations with different enabled status than what we're setting + var organizations = providerOrganizationDetails.Select(po => + { + var org = new Organization { Id = po.OrganizationId, Enabled = !provider.Enabled }; + return org; + }).ToList(); + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails); + + foreach (var org in organizations) + { + organizationRepository.GetByIdAsync(org.Id).Returns(org); + } + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id); + + foreach (var org in organizations) + { + await organizationRepository.Received(1).ReplaceAsync(Arg.Is(o => + o.Id == org.Id && o.Enabled == provider.Enabled)); + await applicationCacheService.Received(1).UpsertOrganizationAbilityAsync(Arg.Is(o => + o.Id == org.Id && o.Enabled == provider.Enabled)); + } + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EnabledStatusChangedAndProviderTypeIsBusinessUnit_CallsUpdateClientOrganizationsEnabledStatus( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.BusinessUnit; // Type that should trigger update + + // Create test provider organization details + var providerOrganizationDetails = new List + { + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }, + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() } + }; + + // Create test organizations with different enabled status than what we're setting + var organizations = providerOrganizationDetails.Select(po => + { + var org = new Organization { Id = po.OrganizationId, Enabled = !provider.Enabled }; + return org; + }).ToList(); + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails); + + foreach (var org in organizations) + { + organizationRepository.GetByIdAsync(org.Id).Returns(org); + } + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id); + + foreach (var org in organizations) + { + await organizationRepository.Received(1).ReplaceAsync(Arg.Is(o => + o.Id == org.Id && o.Enabled == provider.Enabled)); + await applicationCacheService.Received(1).UpsertOrganizationAbilityAsync(Arg.Is(o => + o.Id == org.Id && o.Enabled == provider.Enabled)); + } + } + + [Theory, BitAutoData] + public async Task UpdateAsync_OrganizationEnabledStatusAlreadyMatches_DoesNotUpdateOrganization( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.Msp; // Type that should trigger update + + // Create test provider organization details + var providerOrganizationDetails = new List + { + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }, + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() } + }; + + // Create test organizations with SAME enabled status as what we're setting + var organizations = providerOrganizationDetails.Select(po => + { + var org = new Organization { Id = po.OrganizationId, Enabled = provider.Enabled }; + return org; + }).ToList(); + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails); + + foreach (var org in organizations) + { + organizationRepository.GetByIdAsync(org.Id).Returns(org); + } + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id); + + // Organizations should not be updated since their enabled status already matches + foreach (var org in organizations) + { + await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + await applicationCacheService.DidNotReceive().UpsertOrganizationAbilityAsync(Arg.Any()); + } + } + + [Theory, BitAutoData] + public async Task UpdateAsync_OrganizationIsNull_SkipsNullOrganization( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.Msp; // Type that should trigger update + + // Create test provider organization details + var providerOrganizationDetails = new List + { + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }, + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() } + }; + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails); + + // Return null for all organizations + organizationRepository.GetByIdAsync(Arg.Any()).Returns((Organization)null); + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id); + + // No organizations should be updated since they're all null + await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + await applicationCacheService.DidNotReceive().UpsertOrganizationAbilityAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task InviteUserAsync_ProviderIdIsInvalid_Throws(ProviderUserInvite invite, SutProvider sutProvider) { @@ -937,7 +1120,7 @@ public class ProviderServiceTests private static SubscriptionUpdateOptions SubscriptionUpdateRequest(string expectedPlanId, Subscription subscriptionItem) => new() { - Items = new List + Items = new List { new() { Id = subscriptionItem.Id, Price = expectedPlanId }, } diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs new file mode 100644 index 0000000000..a7f896ef7a --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs @@ -0,0 +1,556 @@ +using Bit.Commercial.Core.Billing.Providers.Queries; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Context; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Stripe; +using Stripe.Tax; +using Xunit; + +namespace Bit.Commercial.Core.Test.Billing.Providers.Queries; + +using static StripeConstants; + +[SutProviderCustomize] +public class GetProviderWarningsQueryTests +{ + private static readonly string[] _requiredExpansions = ["customer.tax_ids"]; + + [Theory, BitAutoData] + public async Task Run_NoSubscription_NoWarnings( + Provider provider, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .ReturnsNull(); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + Suspension: null, + TaxId: null + }); + } + + [Theory, BitAutoData] + public async Task Run_ProviderEnabled_NoSuspensionWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Unpaid, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.Null(response!.Suspension); + } + + [Theory, BitAutoData] + public async Task Run_Has_SuspensionWarning_AddPaymentMethod( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = false; + var cancelAt = DateTime.UtcNow.AddDays(7); + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Unpaid, + CancelAt = cancelAt, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + Suspension.Resolution: "add_payment_method" + }); + Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt); + } + + [Theory, BitAutoData] + public async Task Run_Has_SuspensionWarning_ContactAdministrator( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = false; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Unpaid, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(false); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + Suspension.Resolution: "contact_administrator" + }); + Assert.Null(response.Suspension.SubscriptionCancelsAt); + } + + [Theory, BitAutoData] + public async Task Run_Has_SuspensionWarning_ContactSupport( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = false; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Canceled, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList { Data = [] }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + Suspension.Resolution: "contact_support" + }); + } + + [Theory, BitAutoData] + public async Task Run_NotProviderAdmin_NoTaxIdWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(false); + + var response = await sutProvider.Sut.Run(provider); + + Assert.Null(response!.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_NoTaxRegistrationForCountry_NoTaxIdWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "GB" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.Null(response!.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_Has_TaxIdMissingWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "CA" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + TaxId.Type: "tax_id_missing" + }); + } + + [Theory, BitAutoData] + public async Task Run_TaxIdVerificationIsNull_NoTaxIdWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList + { + Data = [new TaxId { Verification = null }] + }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "CA" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.Null(response!.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_Has_TaxIdPendingVerificationWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList + { + Data = [new TaxId + { + Verification = new TaxIdVerification + { + Status = TaxIdVerificationStatus.Pending + } + }] + }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "CA" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + TaxId.Type: "tax_id_pending_verification" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_TaxIdFailedVerificationWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList + { + Data = [new TaxId + { + Verification = new TaxIdVerification + { + Status = TaxIdVerificationStatus.Unverified + } + }] + }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "CA" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + TaxId.Type: "tax_id_failed_verification" + }); + } + + [Theory, BitAutoData] + public async Task Run_TaxIdVerified_NoTaxIdWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList + { + Data = [new TaxId + { + Verification = new TaxIdVerification + { + Status = TaxIdVerificationStatus.Verified + } + }] + }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "CA" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.Null(response!.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_MultipleRegistrations_MatchesCorrectCountry( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "DE" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Is(opt => opt.Status == TaxRegistrationStatus.Active)) + .Returns(new StripeList + { + Data = [ + new Registration { Country = "US" }, + new Registration { Country = "DE" }, + new Registration { Country = "FR" } + ] + }); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Is(opt => opt.Status == TaxRegistrationStatus.Scheduled)) + .Returns(new StripeList { Data = [] }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + TaxId.Type: "tax_id_missing" + }); + } + + [Theory, BitAutoData] + public async Task Run_CombinesBothWarningTypes( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = false; + var cancelAt = DateTime.UtcNow.AddDays(5); + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Unpaid, + CancelAt = cancelAt, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "CA" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + Suspension.Resolution: "add_payment_method", + TaxId.Type: "tax_id_missing" + }); + Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt); + } + + [Theory, BitAutoData] + public async Task Run_USCustomer_NoTaxIdWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "US" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "US" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.Null(response!.TaxId); + } +} diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs similarity index 99% rename from bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs index c27d990213..ec52650097 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/BusinessUnitConverterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs similarity index 84% rename from bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index 9af9a71cce..18c71364e6 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -1,8 +1,6 @@ using System.Globalization; -using System.Net; using Bit.Commercial.Core.Billing.Providers.Models; using Bit.Commercial.Core.Billing.Providers.Services; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -11,17 +9,16 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -352,9 +349,6 @@ public class ProviderBillingServiceTests CloudRegion = "US" }); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); - sutProvider.GetDependency().CustomerCreateAsync(Arg.Is( options => options.Address.Country == providerCustomer.Address.Country && @@ -898,208 +892,97 @@ public class ProviderBillingServiceTests #region SetupCustomer [Theory, BitAutoData] - public async Task SetupCustomer_MissingCountry_ContactSupport( + public async Task SetupCustomer_NullPaymentMethod_ThrowsNullReferenceException( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { - taxInfo.BillingAddressCountry = null; - - await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CustomerGetAsync(Arg.Any(), Arg.Any()); - } - - [Theory, BitAutoData] - public async Task SetupCustomer_MissingPostalCode_ContactSupport( - SutProvider sutProvider, - Provider provider, - TaxInfo taxInfo) - { - taxInfo.BillingAddressCountry = null; - - await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CustomerGetAsync(Arg.Any(), Arg.Any()); - } - - [Theory, BitAutoData] - public async Task SetupCustomer_NoPaymentMethod_Success( - SutProvider sutProvider, - Provider provider, - TaxInfo taxInfo) - { - provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; - - var stripeAdapter = sutProvider.GetDependency(); - - var expected = new Customer - { - Id = "customer_id", - Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } - }; - - stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && - o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && - o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) - .Returns(expected); - - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo); - - Assert.Equivalent(expected, actual); - } - - [Theory, BitAutoData] - public async Task SetupCustomer_InvalidRequiredPaymentMethod_ThrowsBillingException( - SutProvider sutProvider, - Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) - { - provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); - - tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay }; - - await ThrowsBillingExceptionAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + await Assert.ThrowsAsync(() => + sutProvider.Sut.SetupCustomer(provider, null, billingAddress)); } [Theory, BitAutoData] public async Task SetupCustomer_WithBankAccount_Error_Reverts( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); - - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token"); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" }; stripeAdapter.SetupIntentList(Arg.Is(options => - options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([ + options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([ new SetupIntent { Id = "setup_intent_id" } ]); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Throws(); - sutProvider.GetDependency().Get(provider.Id).Returns("setup_intent_id"); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id"); await Assert.ThrowsAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress)); await sutProvider.GetDependency().Received(1).Set(provider.Id, "setup_intent_id"); await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is(options => options.CancellationReason == "abandoned")); - await sutProvider.GetDependency().Received(1).Remove(provider.Id); + await sutProvider.GetDependency().Received(1).RemoveSetupIntentForSubscriber(provider.Id); } [Theory, BitAutoData] public async Task SetupCustomer_WithPayPal_Error_Reverts( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token"); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); - - sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token) + sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token) .Returns("braintree_customer_id"); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && o.Metadata["btCustomerId"] == "braintree_customer_id" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Throws(); await Assert.ThrowsAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress)); await sutProvider.GetDependency().Customer.Received(1).DeleteAsync("braintree_customer_id"); } @@ -1108,17 +991,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithBankAccount_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); @@ -1128,33 +1005,30 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token"); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" }; stripeAdapter.SetupIntentList(Arg.Is(options => - options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([ + options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([ new SetupIntent { Id = "setup_intent_id" } ]); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); @@ -1165,17 +1039,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithPayPal_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); @@ -1185,32 +1053,29 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token"); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" }; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); - - sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token) + sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token) .Returns("braintree_customer_id"); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && o.Metadata["btCustomerId"] == "braintree_customer_id" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); } @@ -1219,17 +1084,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithCard_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); @@ -1239,30 +1098,26 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.PaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); } @@ -1271,17 +1126,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithCard_ReverseCharge_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "FR"; // Non-US country to trigger reverse charge + billingAddress.TaxId = new TaxID("fr_siren", "123456789"); var stripeAdapter = sutProvider.GetDependency(); @@ -1291,59 +1140,51 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.PaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber && + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value && o.TaxExempt == StripeConstants.TaxExempt.Reverse)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); } [Theory, BitAutoData] - public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid( + public async Task SetupCustomer_WithInvalidTaxId_ThrowsBadRequestException( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "invalid_tax_id"); - taxInfo.BillingAddressCountry = "AD"; + var stripeAdapter = sutProvider.GetDependency(); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns((string)null); + stripeAdapter.CustomerCreateAsync(Arg.Any()) + .Throws(new StripeException("Invalid tax ID") { StripeError = new StripeError { Code = "tax_id_invalid" } }); var actual = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.SetupCustomer(provider, taxInfo)); + await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress)); - Assert.IsType(actual); - Assert.Equal("billingTaxIdTypeInferenceError", actual.Message); + Assert.Equal("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.", actual.Message); } #endregion @@ -1616,8 +1457,6 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( sub => @@ -1694,12 +1533,10 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); const string setupIntentId = "seti_123"; - sutProvider.GetDependency().Get(provider.Id).Returns(setupIntentId); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId); sutProvider.GetDependency().SetupIntentGet(setupIntentId, Arg.Is(options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent @@ -1797,8 +1634,6 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( sub => @@ -1877,11 +1712,6 @@ public class ProviderBillingServiceTests var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active }; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true); sutProvider.GetDependency().SubscriptionCreateAsync(Arg.Is( sub => diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs similarity index 97% rename from bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs rename to bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs index 3087d5761c..8dcf7f6bbc 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/ProviderPriceAdapterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs @@ -1,7 +1,7 @@ -using Bit.Commercial.Core.Billing.Providers.Services; -using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Providers.Services; using Stripe; using Xunit; diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests.cs index 4f87396824..17c92443cc 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests.cs @@ -295,7 +295,7 @@ public class ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests { sutProvider.GetDependency().AccessSecretsManager(resource.OrganizationId) .Returns(true); - sutProvider.GetDependency().GetAccessClientAsync(default, resource.OrganizationId) + sutProvider.GetDependency().GetAccessClientAsync(default!, resource.OrganizationId) .ReturnsForAnyArgs((accessClientType, userId)); } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ServiceAccountGrantedPoliciesAuthorizationHandlerTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ServiceAccountGrantedPoliciesAuthorizationHandlerTests.cs index 6f36684c44..45fe8c588f 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ServiceAccountGrantedPoliciesAuthorizationHandlerTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ServiceAccountGrantedPoliciesAuthorizationHandlerTests.cs @@ -247,7 +247,7 @@ public class ServiceAccountGrantedPoliciesAuthorizationHandlerTests { sutProvider.GetDependency().AccessSecretsManager(resource.OrganizationId) .Returns(true); - sutProvider.GetDependency().GetAccessClientAsync(default, resource.OrganizationId) + sutProvider.GetDependency().GetAccessClientAsync(default!, resource.OrganizationId) .ReturnsForAnyArgs((accessClientType, userId)); } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/Secrets/BulkSecretAuthorizationHandlerTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/Secrets/BulkSecretAuthorizationHandlerTests.cs index d7dc11ba70..a015b1a02a 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/Secrets/BulkSecretAuthorizationHandlerTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/Secrets/BulkSecretAuthorizationHandlerTests.cs @@ -207,7 +207,7 @@ public class BulkSecretAuthorizationHandlerTests { sutProvider.GetDependency().AccessSecretsManager(organizationId) .Returns(true); - sutProvider.GetDependency().GetAccessClientAsync(default, organizationId) + sutProvider.GetDependency().GetAccessClientAsync(default!, organizationId) .ReturnsForAnyArgs((accessClientType, userId)); } diff --git a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs index 44a43d16b7..f391c93fe3 100644 --- a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs @@ -1,10 +1,10 @@ using System.Text.Json; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Scim.Models; using Bit.Scim.Users; using Bit.Scim.Utilities; @@ -101,7 +101,7 @@ public class PatchUserCommandTests await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); - await sutProvider.GetDependency().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM); + await sutProvider.GetDependency().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM); } [Theory] @@ -129,7 +129,7 @@ public class PatchUserCommandTests await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); - await sutProvider.GetDependency().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM); + await sutProvider.GetDependency().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM); } [Theory] @@ -149,7 +149,7 @@ public class PatchUserCommandTests await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM); } [Theory] diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 0ee4aa53a9..c5e42cf9e3 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -53,6 +53,7 @@ services: - ./.data/postgres/log:/var/log/postgresql profiles: - postgres + - ef mysql: image: mysql:8.0 @@ -69,6 +70,7 @@ services: - mysql_dev_data:/var/lib/mysql profiles: - mysql + - ef mariadb: image: mariadb:10 @@ -76,13 +78,13 @@ services: - 4306:3306 environment: MARIADB_USER: maria - MARIADB_PASSWORD: ${MARIADB_ROOT_PASSWORD} MARIADB_DATABASE: vault_dev MARIADB_RANDOM_ROOT_PASSWORD: "true" volumes: - mariadb_dev_data:/var/lib/mysql profiles: - mariadb + - ef idp: image: kenchan0130/simplesamlphp:1.19.8 @@ -99,7 +101,7 @@ services: - idp rabbitmq: - image: rabbitmq:4.1.0-management + image: rabbitmq:4.1.3-management container_name: rabbitmq ports: - "5672:5672" @@ -153,5 +155,6 @@ volumes: mssql_dev_data: postgres_dev_data: mysql_dev_data: + mariadb_dev_data: rabbitmq_data: redis_data: diff --git a/dev/generate_openapi_files.ps1 b/dev/generate_openapi_files.ps1 new file mode 100644 index 0000000000..9eca7dc734 --- /dev/null +++ b/dev/generate_openapi_files.ps1 @@ -0,0 +1,28 @@ +Set-Location "$PSScriptRoot/.." + +$env:ASPNETCORE_ENVIRONMENT = "Development" +$env:swaggerGen = "True" +$env:DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX = "2" +$env:GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING = "placeholder" + +dotnet tool restore + +# Identity +Set-Location "./src/Identity" +dotnet build +dotnet swagger tofile --output "../../identity.json" --host "https://identity.bitwarden.com" "./bin/Debug/net8.0/Identity.dll" "v1" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +# Api internal & public +Set-Location "../../src/Api" +dotnet build +dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} +dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/dev/migrate.ps1 b/dev/migrate.ps1 index 287a2d18ee..26caa87efd 100755 --- a/dev/migrate.ps1 +++ b/dev/migrate.ps1 @@ -70,7 +70,7 @@ Foreach ($item in @( @($mysql, "MySQL", "MySqlMigrations", "mySql", 2), # MariaDB shares the MySQL connection string in the server config so they are mutually exclusive in that context. # However they can still be run independently for integration tests. - @($mariadb, "MariaDB", "MySqlMigrations", "mySql", 3) + @($mariadb, "MariaDB", "MySqlMigrations", "mySql", 4) )) { if (!$item[0] -and !$all) { continue diff --git a/dev/secrets.json.example b/dev/secrets.json.example index 7c91669b39..c6a16846e9 100644 --- a/dev/secrets.json.example +++ b/dev/secrets.json.example @@ -33,6 +33,8 @@ "id": "", "key": "" }, - "licenseDirectory": "" + "licenseDirectory": "", + "enableNewDeviceVerification": true, + "enableEmailVerification": true } } diff --git a/dev/servicebusemulator_config.json b/dev/servicebusemulator_config.json index b107bc6190..294efc1897 100644 --- a/dev/servicebusemulator_config.json +++ b/dev/servicebusemulator_config.json @@ -31,6 +31,12 @@ }, { "Name": "events-webhook-subscription" + }, + { + "Name": "events-hec-subscription" + }, + { + "Name": "events-datadog-subscription" } ] }, @@ -64,6 +70,34 @@ } } ] + }, + { + "Name": "integration-hec-subscription", + "Rules": [ + { + "Name": "hec-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "hec" + } + } + } + ] + }, + { + "Name": "integration-datadog-subscription", + "Rules": [ + { + "Name": "datadog-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "datadog" + } + } + } + ] } ] } diff --git a/perf/MicroBenchmarks/Identity/IdentityServer/StaticClientStoreTests.cs b/perf/MicroBenchmarks/Identity/IdentityServer/StaticClientStoreTests.cs index 9d88f960ea..fcdda22c10 100644 --- a/perf/MicroBenchmarks/Identity/IdentityServer/StaticClientStoreTests.cs +++ b/perf/MicroBenchmarks/Identity/IdentityServer/StaticClientStoreTests.cs @@ -20,7 +20,7 @@ public class StaticClientStoreTests [Benchmark] public Client? TryGetValue() { - return _store.ApiClients.TryGetValue(ClientId, out var client) + return _store.Clients.TryGetValue(ClientId, out var client) ? client : null; } diff --git a/perf/MicroBenchmarks/MicroBenchmarks.csproj b/perf/MicroBenchmarks/MicroBenchmarks.csproj index 82c526a7d2..a13792b2d6 100644 --- a/perf/MicroBenchmarks/MicroBenchmarks.csproj +++ b/perf/MicroBenchmarks/MicroBenchmarks.csproj @@ -7,7 +7,7 @@ - + diff --git a/perf/load/config.js b/perf/load/config.js index f4e1b33bc0..ab7bb8d2fa 100644 --- a/perf/load/config.js +++ b/perf/load/config.js @@ -9,12 +9,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL; const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH; export const options = { - ext: { - loadimpact: { - projectID: 3639465, - name: "Config", - }, - }, scenarios: { constant_load: { executor: "constant-arrival-rate", diff --git a/perf/load/groups.js b/perf/load/groups.js index aee3b3e94d..71e8decdcb 100644 --- a/perf/load/groups.js +++ b/perf/load/groups.js @@ -10,12 +10,6 @@ const AUTH_CLIENT_ID = __ENV.AUTH_CLIENT_ID; const AUTH_CLIENT_SECRET = __ENV.AUTH_CLIENT_SECRET; export const options = { - ext: { - loadimpact: { - projectID: 3639465, - name: "Groups", - }, - }, scenarios: { constant_load: { executor: "constant-arrival-rate", diff --git a/perf/load/login.js b/perf/load/login.js index 096974f599..d45b86da5f 100644 --- a/perf/load/login.js +++ b/perf/load/login.js @@ -6,12 +6,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL; const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH; export const options = { - ext: { - loadimpact: { - projectID: 3639465, - name: "Login", - }, - }, scenarios: { constant_load: { executor: "constant-arrival-rate", diff --git a/perf/load/sync.js b/perf/load/sync.js index 5624803e84..2eb2a54403 100644 --- a/perf/load/sync.js +++ b/perf/load/sync.js @@ -9,12 +9,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL; const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH; export const options = { - ext: { - loadimpact: { - projectID: 3639465, - name: "Sync", - }, - }, scenarios: { constant_load: { executor: "constant-arrival-rate", diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index ecdd372df4..2417bf610d 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Admin.AdminConsole.Models; using Bit.Admin.Enums; using Bit.Admin.Services; @@ -6,6 +9,7 @@ using Bit.Admin.Utilities; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; @@ -29,7 +33,6 @@ namespace Bit.Admin.AdminConsole.Controllers; [Authorize] public class OrganizationsController : Controller { - private readonly IOrganizationService _organizationService; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationConnectionRepository _organizationConnectionRepository; @@ -52,9 +55,9 @@ public class OrganizationsController : Controller private readonly IProviderBillingService _providerBillingService; private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand; private readonly IPricingClient _pricingClient; + private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; public OrganizationsController( - IOrganizationService organizationService, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationConnectionRepository organizationConnectionRepository, @@ -76,9 +79,9 @@ public class OrganizationsController : Controller IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, IProviderBillingService providerBillingService, IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand, - IPricingClient pricingClient) + IPricingClient pricingClient, + IResendOrganizationInviteCommand resendOrganizationInviteCommand) { - _organizationService = organizationService; _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _organizationConnectionRepository = organizationConnectionRepository; @@ -101,6 +104,7 @@ public class OrganizationsController : Controller _providerBillingService = providerBillingService; _organizationInitiateDeleteCommand = organizationInitiateDeleteCommand; _pricingClient = pricingClient; + _resendOrganizationInviteCommand = resendOrganizationInviteCommand; } [RequirePermission(Permission.Org_List_View)] @@ -392,7 +396,7 @@ public class OrganizationsController : Controller var organizationUsers = await _organizationUserRepository.GetManyByOrganizationAsync(id, OrganizationUserType.Owner); foreach (var organizationUser in organizationUsers) { - await _organizationService.ResendInviteAsync(id, null, organizationUser.Id, true); + await _resendOrganizationInviteCommand.ResendInviteAsync(id, null, organizationUser.Id, true); } return Json(null); diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 7f11b65d9e..9344179a77 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -1,9 +1,12 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Net; using Bit.Admin.AdminConsole.Models; using Bit.Admin.Enums; +using Bit.Admin.Services; using Bit.Admin.Utilities; -using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; @@ -18,6 +21,7 @@ using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -34,26 +38,26 @@ namespace Bit.Admin.AdminConsole.Controllers; [SelfHosted(NotSelfHostedOnly = true)] public class ProvidersController : Controller { + private readonly string _stripeUrl; + private readonly string _braintreeMerchantUrl; + private readonly string _braintreeMerchantId; private readonly IOrganizationRepository _organizationRepository; private readonly IResellerClientOrganizationSignUpCommand _resellerClientOrganizationSignUpCommand; private readonly IProviderRepository _providerRepository; private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository; + private readonly IProviderService _providerService; private readonly GlobalSettings _globalSettings; private readonly IApplicationCacheService _applicationCacheService; - private readonly IProviderService _providerService; private readonly ICreateProviderCommand _createProviderCommand; - private readonly IFeatureService _featureService; private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderBillingService _providerBillingService; private readonly IPricingClient _pricingClient; private readonly IStripeAdapter _stripeAdapter; - private readonly string _stripeUrl; - private readonly string _braintreeMerchantUrl; - private readonly string _braintreeMerchantId; + private readonly IAccessControlService _accessControlService; + private readonly ISubscriberService _subscriberService; - public ProvidersController( - IOrganizationRepository organizationRepository, + public ProvidersController(IOrganizationRepository organizationRepository, IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand, IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, @@ -62,12 +66,13 @@ public class ProvidersController : Controller GlobalSettings globalSettings, IApplicationCacheService applicationCacheService, ICreateProviderCommand createProviderCommand, - IFeatureService featureService, IProviderPlanRepository providerPlanRepository, IProviderBillingService providerBillingService, IWebHostEnvironment webHostEnvironment, IPricingClient pricingClient, - IStripeAdapter stripeAdapter) + IStripeAdapter stripeAdapter, + IAccessControlService accessControlService, + ISubscriberService subscriberService) { _organizationRepository = organizationRepository; _resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand; @@ -78,14 +83,15 @@ public class ProvidersController : Controller _globalSettings = globalSettings; _applicationCacheService = applicationCacheService; _createProviderCommand = createProviderCommand; - _featureService = featureService; _providerPlanRepository = providerPlanRepository; _providerBillingService = providerBillingService; _pricingClient = pricingClient; _stripeAdapter = stripeAdapter; + _accessControlService = accessControlService; _stripeUrl = webHostEnvironment.GetStripeUrl(); _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantId = globalSettings.Braintree.MerchantId; + _subscriberService = subscriberService; } [RequirePermission(Permission.Provider_List_View)] @@ -288,9 +294,31 @@ public class ProvidersController : Controller return View(oldModel); } + var originalProviderStatus = provider.Enabled; + model.ToProvider(provider); - await _providerRepository.ReplaceAsync(provider); + // validate the stripe ids to prevent saving a bad one + if (provider.IsBillable()) + { + if (!await _subscriberService.IsValidGatewayCustomerIdAsync(provider)) + { + var oldModel = await GetEditModel(id); + ModelState.AddModelError(nameof(model.GatewayCustomerId), $"Invalid Gateway Customer Id: {model.GatewayCustomerId}"); + return View(oldModel); + } + if (!await _subscriberService.IsValidGatewaySubscriptionIdAsync(provider)) + { + var oldModel = await GetEditModel(id); + ModelState.AddModelError(nameof(model.GatewaySubscriptionId), $"Invalid Gateway Subscription Id: {model.GatewaySubscriptionId}"); + return View(oldModel); + } + } + + provider.Enabled = _accessControlService.UserHasPermission(Permission.Provider_CheckEnabledBox) + ? model.Enabled : originalProviderStatus; + + await _providerService.UpdateAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider); if (!provider.IsBillable()) @@ -311,21 +339,17 @@ public class ProvidersController : Controller ]); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); - if (_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically)) + var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId); + if (model.PayByInvoice != customer.ApprovedToPayByInvoice()) { - var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId); - - if (model.PayByInvoice != customer.ApprovedToPayByInvoice()) + var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0"; + await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { - var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0"; - await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + Metadata = new Dictionary { - Metadata = new Dictionary - { - [StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice - } - }); - } + [StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice + } + }); } break; case ProviderType.BusinessUnit: @@ -370,10 +394,7 @@ public class ProvidersController : Controller } var providerPlans = await _providerPlanRepository.GetByProviderId(id); - - var payByInvoice = - _featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && - (await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice(); + var payByInvoice = ((await _subscriberService.GetCustomer(provider))?.ApprovedToPayByInvoice() ?? false); return new ProviderEditModel( provider, users, providerOrganizations, diff --git a/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs b/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs index b57d90e33b..fd83ba8e5d 100644 --- a/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; diff --git a/src/Admin/AdminConsole/Models/CreateMspProviderModel.cs b/src/Admin/AdminConsole/Models/CreateMspProviderModel.cs index 4ada2d4a5f..4832910d4c 100644 --- a/src/Admin/AdminConsole/Models/CreateMspProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateMspProviderModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.SharedWeb.Utilities; diff --git a/src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs b/src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs index 958faf3f85..0bb3ea47bb 100644 --- a/src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.SharedWeb.Utilities; diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index c79124688e..b64af3135f 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Net; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; diff --git a/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs b/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs index 5e9055be55..f3d9ae1dd8 100644 --- a/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Admin.AdminConsole.Models; diff --git a/src/Admin/AdminConsole/Models/OrganizationUnassignedToProviderSearchViewModel.cs b/src/Admin/AdminConsole/Models/OrganizationUnassignedToProviderSearchViewModel.cs index cbf15a4776..26c27f01b5 100644 --- a/src/Admin/AdminConsole/Models/OrganizationUnassignedToProviderSearchViewModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationUnassignedToProviderSearchViewModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Admin.Models; namespace Bit.Admin.AdminConsole.Models; diff --git a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs index 412b17b3d7..2c126ecd8e 100644 --- a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Admin/AdminConsole/Models/OrganizationsModel.cs b/src/Admin/AdminConsole/Models/OrganizationsModel.cs index a98985ef01..6bfec24486 100644 --- a/src/Admin/AdminConsole/Models/OrganizationsModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationsModel.cs @@ -1,4 +1,7 @@ -using Bit.Admin.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Admin.Models; using Bit.Core.AdminConsole.Entities; namespace Bit.Admin.AdminConsole.Models; diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index de9e25fa6f..a96c3bd236 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -1,8 +1,12 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Enums; using Bit.SharedWeb.Utilities; @@ -35,6 +39,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject GatewaySubscriptionUrl = gatewaySubscriptionUrl; Type = provider.Type; PayByInvoice = payByInvoice; + Enabled = provider.Enabled; if (Type == ProviderType.BusinessUnit) { @@ -75,18 +80,21 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject [Display(Name = "Enterprise Seats Minimum")] public int? EnterpriseMinimumSeats { get; set; } + [Display(Name = "Enabled")] + public bool Enabled { get; set; } + public virtual Provider ToProvider(Provider existingProvider) { existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim(); existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim(); - switch (Type) + existingProvider.Enabled = Enabled; + if (Type.IsStripeSupported()) { - case ProviderType.Msp: - existingProvider.Gateway = Gateway; - existingProvider.GatewayCustomerId = GatewayCustomerId; - existingProvider.GatewaySubscriptionId = GatewaySubscriptionId; - break; + existingProvider.Gateway = Gateway; + existingProvider.GatewayCustomerId = GatewayCustomerId; + existingProvider.GatewaySubscriptionId = GatewaySubscriptionId; } + return existingProvider; } diff --git a/src/Admin/AdminConsole/Models/ProviderViewModel.cs b/src/Admin/AdminConsole/Models/ProviderViewModel.cs index e1277f8e87..f6e16d270d 100644 --- a/src/Admin/AdminConsole/Models/ProviderViewModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderViewModel.cs @@ -1,4 +1,7 @@ -using Bit.Admin.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Admin.Billing.Models; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; diff --git a/src/Admin/AdminConsole/Models/ProvidersModel.cs b/src/Admin/AdminConsole/Models/ProvidersModel.cs index 6de815facf..ea7b0aa4f0 100644 --- a/src/Admin/AdminConsole/Models/ProvidersModel.cs +++ b/src/Admin/AdminConsole/Models/ProvidersModel.cs @@ -1,4 +1,7 @@ -using Bit.Admin.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Admin.Models; using Bit.Core.AdminConsole.Entities.Provider; namespace Bit.Admin.AdminConsole.Models; diff --git a/src/Admin/AdminConsole/Views/Organizations/Connections.cshtml b/src/Admin/AdminConsole/Views/Organizations/Connections.cshtml index 6efdb34b20..7d2a409715 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Connections.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Connections.cshtml @@ -52,7 +52,7 @@ @if(connection.Enabled) { - @if(@TempData["ConnectionActivated"] != null && @TempData["ConnectionActivated"].ToString() == @Model.Organization.Id.ToString()) + @if(@TempData["ConnectionActivated"] != null && @TempData["ConnectionActivated"]!.ToString() == @Model.Organization.Id.ToString()) { @if(connection.Type.Equals(OrganizationConnectionType.CloudBillingSync)) { diff --git a/src/Admin/AdminConsole/Views/Organizations/_ProviderInformation.cshtml b/src/Admin/AdminConsole/Views/Organizations/_ProviderInformation.cshtml index 03ecad452d..f6e068e0ae 100644 --- a/src/Admin/AdminConsole/Views/Organizations/_ProviderInformation.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/_ProviderInformation.cshtml @@ -2,7 +2,9 @@ @model Bit.Core.AdminConsole.Entities.Provider.Provider
Provider Name
-
@Model.DisplayName()
+
+ @Model.DisplayName() +
Provider Type
@(Model.Type.GetDisplayAttribute()?.GetName())
diff --git a/src/Admin/AdminConsole/Views/Providers/Admins.cshtml b/src/Admin/AdminConsole/Views/Providers/Admins.cshtml index 29eddc8964..fb258bec46 100644 --- a/src/Admin/AdminConsole/Views/Providers/Admins.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Admins.cshtml @@ -53,7 +53,7 @@ && @Model.Provider.Status.Equals(ProviderStatusType.Pending) && canResendEmailInvite) { - @if(@TempData["InviteResentTo"] != null && @TempData["InviteResentTo"].ToString() == @user.UserId.Value.ToString()) + @if(@TempData["InviteResentTo"] != null && @TempData["InviteResentTo"]!.ToString() == @user.UserId!.Value.ToString()) { } diff --git a/src/Admin/AdminConsole/Views/Providers/CreateOrganization.cshtml b/src/Admin/AdminConsole/Views/Providers/CreateOrganization.cshtml index eb790f20ba..148c2e0c2d 100644 --- a/src/Admin/AdminConsole/Views/Providers/CreateOrganization.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/CreateOrganization.cshtml @@ -8,7 +8,7 @@ } diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index d2a9ed1f62..e450322e97 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -1,16 +1,16 @@ -@using Bit.Admin.Enums; -@using Bit.Core +@inject IAccessControlService AccessControlService + +@using Bit.Admin.Enums +@using Bit.Admin.Services @using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.Billing.Enums @using Bit.Core.Billing.Extensions -@using Microsoft.AspNetCore.Mvc.TagHelpers -@inject Bit.Admin.Services.IAccessControlService AccessControlService -@inject Bit.Core.Services.IFeatureService FeatureService - +@using Bit.Core.Enums @model ProviderEditModel @{ ViewData["Title"] = "Provider: " + Model.Provider.DisplayName(); var canEdit = AccessControlService.UserHasPermission(Permission.Provider_Edit); + var canCheckEnabled = AccessControlService.UserHasPermission(Permission.Provider_CheckEnabledBox); }

Provider @Model.Provider.DisplayName()

@@ -30,6 +30,13 @@
Name
@Model.Provider.DisplayName()
+ @if (canCheckEnabled && (Model.Provider.Type == ProviderType.Msp || Model.Provider.Type == ProviderType.BusinessUnit)) + { +
+ + +
+ }

Business Information

Business Name
@@ -106,7 +113,7 @@
-
@@ -136,7 +143,7 @@
- @if (FeatureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && Model.Provider.Type == ProviderType.Msp && Model.Provider.IsBillable()) + @if (Model.Provider.Type == ProviderType.Msp && Model.Provider.IsBillable()) {
diff --git a/src/Admin/AdminConsole/Views/Providers/_ViewInformation.cshtml b/src/Admin/AdminConsole/Views/Providers/_ViewInformation.cshtml index 5d18d7a651..81debddbeb 100644 --- a/src/Admin/AdminConsole/Views/Providers/_ViewInformation.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/_ViewInformation.cshtml @@ -14,6 +14,12 @@
Provider Type
@(Model.Provider.Type.GetDisplayAttribute()?.GetName())
+ @if (Model.Provider.Type == ProviderType.Msp || Model.Provider.Type == ProviderType.BusinessUnit) + { +
Enabled
+
@(Model.Provider.Enabled ? "Yes" : "No")
+ } +
Created
@Model.Provider.CreationDate.ToString()
diff --git a/src/Admin/AdminSettings.cs b/src/Admin/AdminSettings.cs index 18694e3e38..0ecae5c82e 100644 --- a/src/Admin/AdminSettings.cs +++ b/src/Admin/AdminSettings.cs @@ -1,4 +1,7 @@ -namespace Bit.Admin; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Admin; public class AdminSettings { diff --git a/src/Admin/Auth/Controllers/LoginController.cs b/src/Admin/Auth/Controllers/LoginController.cs index dbc04e96c0..7be161e6d9 100644 --- a/src/Admin/Auth/Controllers/LoginController.cs +++ b/src/Admin/Auth/Controllers/LoginController.cs @@ -1,4 +1,7 @@ -using Bit.Admin.Auth.IdentityServer; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Admin.Auth.IdentityServer; using Bit.Admin.Auth.Models; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; diff --git a/src/Admin/Auth/Models/LoginModel.cs b/src/Admin/Auth/Models/LoginModel.cs index 7dd8521a4f..2a1eab0d7c 100644 --- a/src/Admin/Auth/Models/LoginModel.cs +++ b/src/Admin/Auth/Models/LoginModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Admin.Auth.Models; diff --git a/src/Admin/Billing/Controllers/ProcessStripeEventsController.cs b/src/Admin/Billing/Controllers/ProcessStripeEventsController.cs index 1a3f56a183..80ad7fef4e 100644 --- a/src/Admin/Billing/Controllers/ProcessStripeEventsController.cs +++ b/src/Admin/Billing/Controllers/ProcessStripeEventsController.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Admin.Billing.Models.ProcessStripeEvents; using Bit.Core.Settings; using Bit.Core.Utilities; diff --git a/src/Admin/Billing/Models/MigrateProvidersRequestModel.cs b/src/Admin/Billing/Models/MigrateProvidersRequestModel.cs index fe1d88e224..273f934eba 100644 --- a/src/Admin/Billing/Models/MigrateProvidersRequestModel.cs +++ b/src/Admin/Billing/Models/MigrateProvidersRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Admin.Billing.Models; diff --git a/src/Admin/Billing/Models/ProcessStripeEvents/EventsFormModel.cs b/src/Admin/Billing/Models/ProcessStripeEvents/EventsFormModel.cs index 5ead00e263..b78d8cc89e 100644 --- a/src/Admin/Billing/Models/ProcessStripeEvents/EventsFormModel.cs +++ b/src/Admin/Billing/Models/ProcessStripeEvents/EventsFormModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel; using System.ComponentModel.DataAnnotations; namespace Bit.Admin.Billing.Models.ProcessStripeEvents; diff --git a/src/Admin/Billing/Models/ProcessStripeEvents/EventsRequestBody.cs b/src/Admin/Billing/Models/ProcessStripeEvents/EventsRequestBody.cs index 05a2444605..ea9b9c1045 100644 --- a/src/Admin/Billing/Models/ProcessStripeEvents/EventsRequestBody.cs +++ b/src/Admin/Billing/Models/ProcessStripeEvents/EventsRequestBody.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Admin.Billing.Models.ProcessStripeEvents; diff --git a/src/Admin/Billing/Models/ProcessStripeEvents/EventsResponseBody.cs b/src/Admin/Billing/Models/ProcessStripeEvents/EventsResponseBody.cs index 84eeb35d29..5c55b3a8b4 100644 --- a/src/Admin/Billing/Models/ProcessStripeEvents/EventsResponseBody.cs +++ b/src/Admin/Billing/Models/ProcessStripeEvents/EventsResponseBody.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Admin.Billing.Models.ProcessStripeEvents; diff --git a/src/Admin/Controllers/HomeController.cs b/src/Admin/Controllers/HomeController.cs index 20c1be70d0..debe5979f5 100644 --- a/src/Admin/Controllers/HomeController.cs +++ b/src/Admin/Controllers/HomeController.cs @@ -1,4 +1,7 @@ -using System.Diagnostics; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Diagnostics; using System.Text.Json; using Bit.Admin.Models; using Bit.Core.Settings; diff --git a/src/Admin/Controllers/ToolsController.cs b/src/Admin/Controllers/ToolsController.cs index eaf3de4be5..b754b1f968 100644 --- a/src/Admin/Controllers/ToolsController.cs +++ b/src/Admin/Controllers/ToolsController.cs @@ -1,13 +1,16 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text; using System.Text.Json; using Bit.Admin.Enums; using Bit.Admin.Models; using Bit.Admin.Utilities; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Entities; using Bit.Core.Models.BitStripe; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.Services; @@ -24,7 +27,7 @@ public class ToolsController : Controller { private readonly GlobalSettings _globalSettings; private readonly IOrganizationRepository _organizationRepository; - private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; + private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery; private readonly IUserService _userService; private readonly ITransactionRepository _transactionRepository; private readonly IInstallationRepository _installationRepository; @@ -37,7 +40,7 @@ public class ToolsController : Controller public ToolsController( GlobalSettings globalSettings, IOrganizationRepository organizationRepository, - ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, + IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery, IUserService userService, ITransactionRepository transactionRepository, IInstallationRepository installationRepository, @@ -49,7 +52,7 @@ public class ToolsController : Controller { _globalSettings = globalSettings; _organizationRepository = organizationRepository; - _cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery; + _getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery; _userService = userService; _transactionRepository = transactionRepository; _installationRepository = installationRepository; @@ -317,7 +320,7 @@ public class ToolsController : Controller if (organization != null) { - var license = await _cloudGetOrganizationLicenseQuery.GetLicenseAsync(organization, + var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, model.InstallationId.Value, model.Version); var ms = new MemoryStream(); await JsonSerializer.SerializeAsync(ms, license, JsonHelpers.Indented); diff --git a/src/Admin/Dockerfile b/src/Admin/Dockerfile index 0d6fd4cc78..648ff1be91 100644 --- a/src/Admin/Dockerfile +++ b/src/Admin/Dockerfile @@ -1,40 +1,41 @@ +############################################### +# Node.js build stage # +############################################### +FROM node:20-alpine3.21 AS node-build + +WORKDIR /app +COPY src/Admin/package*.json ./ +COPY /src/Admin/ . +RUN npm ci +RUN npm run build + ############################################### # Build stage # ############################################### -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build # Docker buildx supplies the value for this arg ARG TARGETPLATFORM # Determine proper runtime value for .NET RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ - RID=linux-x64 ; \ + RID=linux-musl-x64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ - RID=linux-arm64 ; \ + RID=linux-musl-arm64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ - RID=linux-arm ; \ + RID=linux-musl-arm ; \ fi \ && echo "RID=$RID" > /tmp/rid.txt -# Set up Node -ARG NODE_VERSION=20 -RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \ - && apt-get update \ - && apt-get install -y nodejs \ - && npm install -g npm@latest && \ - rm -rf /var/lib/apt/lists/* - # Copy required project files WORKDIR /source COPY . ./ # Restore project dependencies and tools WORKDIR /source/src/Admin -RUN npm ci RUN . /tmp/rid.txt && dotnet restore -r $RID # Build project -RUN npm run build RUN . /tmp/rid.txt && dotnet publish \ -c release \ --no-restore \ @@ -46,25 +47,27 @@ RUN . /tmp/rid.txt && dotnet publish \ ############################################### # App stage # ############################################### -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_URLS=http://+:5000 ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - krb5-user \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache curl \ + icu-libs \ + tzdata \ + krb5 \ + shadow \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu # Copy app from the build stage WORKDIR /app COPY --from=build /source/src/Admin/out /app +COPY --from=node-build /app/wwwroot /app/wwwroot COPY ./src/Admin/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 diff --git a/src/Admin/Enums/Permissions.cs b/src/Admin/Enums/Permissions.cs index 704fd770bb..14b255b2b6 100644 --- a/src/Admin/Enums/Permissions.cs +++ b/src/Admin/Enums/Permissions.cs @@ -45,6 +45,7 @@ public enum Permission Provider_Edit, Provider_View, Provider_ResendEmailInvite, + Provider_CheckEnabledBox, Tools_ChargeBrainTreeCustomer, Tools_PromoteAdmin, diff --git a/src/Admin/HostedServices/AzureQueueMailHostedService.cs b/src/Admin/HostedServices/AzureQueueMailHostedService.cs index cff724e4f3..4669b2b2ec 100644 --- a/src/Admin/HostedServices/AzureQueueMailHostedService.cs +++ b/src/Admin/HostedServices/AzureQueueMailHostedService.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Azure.Storage.Queues; using Azure.Storage.Queues.Models; using Bit.Core.Models.Mail; diff --git a/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs b/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs index 89f04230b3..ba5c6c0cfd 100644 --- a/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs +++ b/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs @@ -1,4 +1,7 @@ -using Bit.Core.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Utilities; using Microsoft.AspNetCore.Identity; namespace Bit.Admin.IdentityServer; diff --git a/src/Admin/IdentityServer/ReadOnlyIdentityUserStore.cs b/src/Admin/IdentityServer/ReadOnlyIdentityUserStore.cs index 88f3a40b1a..4a81745241 100644 --- a/src/Admin/IdentityServer/ReadOnlyIdentityUserStore.cs +++ b/src/Admin/IdentityServer/ReadOnlyIdentityUserStore.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Identity; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Identity; namespace Bit.Admin.IdentityServer; diff --git a/src/Admin/Jobs/DeleteCiphersJob.cs b/src/Admin/Jobs/DeleteCiphersJob.cs index ee48a26d16..b1fc9c53c6 100644 --- a/src/Admin/Jobs/DeleteCiphersJob.cs +++ b/src/Admin/Jobs/DeleteCiphersJob.cs @@ -1,4 +1,7 @@ -using Bit.Core; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core; using Bit.Core.Jobs; using Bit.Core.Vault.Repositories; using Microsoft.Extensions.Options; diff --git a/src/Admin/Models/BillingInformationModel.cs b/src/Admin/Models/BillingInformationModel.cs index ecc06919fa..c6c7ce82c9 100644 --- a/src/Admin/Models/BillingInformationModel.cs +++ b/src/Admin/Models/BillingInformationModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models; namespace Bit.Admin.Models; diff --git a/src/Admin/Models/ChargeBraintreeModel.cs b/src/Admin/Models/ChargeBraintreeModel.cs index 8c2f39e58d..195c0a1f0c 100644 --- a/src/Admin/Models/ChargeBraintreeModel.cs +++ b/src/Admin/Models/ChargeBraintreeModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Admin.Models; diff --git a/src/Admin/Models/CreateUpdateTransactionModel.cs b/src/Admin/Models/CreateUpdateTransactionModel.cs index 8004546f9e..41b7a30413 100644 --- a/src/Admin/Models/CreateUpdateTransactionModel.cs +++ b/src/Admin/Models/CreateUpdateTransactionModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Admin/Models/CursorPagedModel.cs b/src/Admin/Models/CursorPagedModel.cs index 35a4de922a..b6475ad220 100644 --- a/src/Admin/Models/CursorPagedModel.cs +++ b/src/Admin/Models/CursorPagedModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Admin.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Admin.Models; public class CursorPagedModel { diff --git a/src/Admin/Models/ErrorViewModel.cs b/src/Admin/Models/ErrorViewModel.cs index 3b24a1ece7..dc39c2f004 100644 --- a/src/Admin/Models/ErrorViewModel.cs +++ b/src/Admin/Models/ErrorViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Admin.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Admin.Models; public class ErrorViewModel { diff --git a/src/Admin/Models/HomeModel.cs b/src/Admin/Models/HomeModel.cs index 900a04e41a..f4006d6c30 100644 --- a/src/Admin/Models/HomeModel.cs +++ b/src/Admin/Models/HomeModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Settings; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Settings; namespace Bit.Admin.Models; diff --git a/src/Admin/Models/PagedModel.cs b/src/Admin/Models/PagedModel.cs index 4c9c8e1713..3fec874ae5 100644 --- a/src/Admin/Models/PagedModel.cs +++ b/src/Admin/Models/PagedModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Admin.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Admin.Models; public abstract class PagedModel { diff --git a/src/Admin/Models/StripeSubscriptionsModel.cs b/src/Admin/Models/StripeSubscriptionsModel.cs index 99e9c5b77a..36e1f099e1 100644 --- a/src/Admin/Models/StripeSubscriptionsModel.cs +++ b/src/Admin/Models/StripeSubscriptionsModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Models.BitStripe; namespace Bit.Admin.Models; diff --git a/src/Admin/Models/UserEditModel.cs b/src/Admin/Models/UserEditModel.cs index 2597da6e96..cfbb05a5ac 100644 --- a/src/Admin/Models/UserEditModel.cs +++ b/src/Admin/Models/UserEditModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Settings; diff --git a/src/Admin/Models/UserViewModel.cs b/src/Admin/Models/UserViewModel.cs index 7fddbc0f54..719ad7813c 100644 --- a/src/Admin/Models/UserViewModel.cs +++ b/src/Admin/Models/UserViewModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Vault.Entities; diff --git a/src/Admin/Models/UsersModel.cs b/src/Admin/Models/UsersModel.cs index 33148301b2..191a34547d 100644 --- a/src/Admin/Models/UsersModel.cs +++ b/src/Admin/Models/UsersModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Admin.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Admin.Models; public class UsersModel : PagedModel { diff --git a/src/Admin/Services/AccessControlService.cs b/src/Admin/Services/AccessControlService.cs index a2ba9fa6ff..f512ec7494 100644 --- a/src/Admin/Services/AccessControlService.cs +++ b/src/Admin/Services/AccessControlService.cs @@ -1,4 +1,7 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Admin.Enums; using Bit.Admin.Utilities; using Bit.Core.Settings; diff --git a/src/Admin/TagHelpers/ActivePageTagHelper.cs b/src/Admin/TagHelpers/ActivePageTagHelper.cs index a148e3cdf7..bc8e9afafb 100644 --- a/src/Admin/TagHelpers/ActivePageTagHelper.cs +++ b/src/Admin/TagHelpers/ActivePageTagHelper.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Mvc.Controllers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; diff --git a/src/Admin/Utilities/RolePermissionMapping.cs b/src/Admin/Utilities/RolePermissionMapping.cs index f342dfce7c..b60cf895a1 100644 --- a/src/Admin/Utilities/RolePermissionMapping.cs +++ b/src/Admin/Utilities/RolePermissionMapping.cs @@ -47,6 +47,7 @@ public static class RolePermissionMapping Permission.Provider_Create, Permission.Provider_View, Permission.Provider_ResendEmailInvite, + Permission.Provider_CheckEnabledBox, Permission.Tools_ChargeBrainTreeCustomer, Permission.Tools_PromoteAdmin, Permission.Tools_PromoteProviderServiceUser, @@ -98,6 +99,7 @@ public static class RolePermissionMapping Permission.Provider_View, Permission.Provider_Edit, Permission.Provider_ResendEmailInvite, + Permission.Provider_CheckEnabledBox, Permission.Tools_ChargeBrainTreeCustomer, Permission.Tools_PromoteAdmin, Permission.Tools_PromoteProviderServiceUser, @@ -135,7 +137,8 @@ public static class RolePermissionMapping Permission.Org_Billing_LaunchGateway, Permission.Org_RequestDelete, Permission.Provider_List_View, - Permission.Provider_View + Permission.Provider_View, + Permission.Provider_CheckEnabledBox } }, { "billing", new List @@ -173,6 +176,7 @@ public static class RolePermissionMapping Permission.Provider_Edit, Permission.Provider_View, Permission.Provider_List_View, + Permission.Provider_CheckEnabledBox, Permission.Tools_ChargeBrainTreeCustomer, Permission.Tools_GenerateLicenseFile, Permission.Tools_ManageTaxRates, diff --git a/src/Admin/Views/Tools/ChargeBraintree.cshtml b/src/Admin/Views/Tools/ChargeBraintree.cshtml index aaf3bbf167..0c661a8ee4 100644 --- a/src/Admin/Views/Tools/ChargeBraintree.cshtml +++ b/src/Admin/Views/Tools/ChargeBraintree.cshtml @@ -8,7 +8,7 @@ @if(!string.IsNullOrWhiteSpace(Model.TransactionId)) { diff --git a/src/Admin/entrypoint.sh b/src/Admin/entrypoint.sh index 4d7d238d25..21bb61716c 100644 --- a/src/Admin/entrypoint.sh +++ b/src/Admin/entrypoint.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # Setup @@ -37,7 +37,7 @@ then mkdir -p /etc/bitwarden/ca-certificates chown -R $USERNAME:$GROUPNAME /etc/bitwarden - if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos fi @@ -46,7 +46,7 @@ else gosu_cmd="" fi -if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then +if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index e73ccfcef5..2e3a335598 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -18,9 +18,9 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.88.0", + "sass": "1.91.0", "sass-loader": "16.0.5", - "webpack": "5.99.8", + "webpack": "5.101.3", "webpack-cli": "5.1.4" } }, @@ -35,18 +35,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -59,20 +55,10 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", "dependencies": { @@ -81,16 +67,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -442,9 +428,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -456,13 +442,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", - "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.10.0" } }, "node_modules/@webassemblyjs/ast": { @@ -688,9 +674,9 @@ "license": "Apache-2.0" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -700,6 +686,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -782,9 +781,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", "dev": true, "funding": [ { @@ -802,8 +801,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -822,9 +821,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "version": "1.0.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", "dev": true, "funding": [ { @@ -976,16 +975,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.155", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", - "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", + "version": "1.5.215", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz", + "integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, "license": "MIT", "dependencies": { @@ -1108,9 +1107,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, "funding": [ { @@ -1242,9 +1241,9 @@ } }, "node_modules/immutable": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", - "integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", "dev": true, "license": "MIT" }, @@ -1529,9 +1528,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", + "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", "dev": true, "license": "MIT" }, @@ -1636,9 +1635,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -1656,7 +1655,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1861,9 +1860,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.88.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz", - "integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==", + "version": "1.91.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", + "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "dev": true, "license": "MIT", "dependencies": { @@ -2062,24 +2061,28 @@ } }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { - "version": "5.39.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", - "integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==", + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -2148,9 +2151,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", "dev": true, "license": "MIT" }, @@ -2207,22 +2210,23 @@ } }, "node_modules/webpack": { - "version": "5.99.8", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", - "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", + "version": "5.101.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", + "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", + "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -2236,7 +2240,7 @@ "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -2326,9 +2330,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "license": "MIT", "engines": { diff --git a/src/Admin/package.json b/src/Admin/package.json index e88cd42eca..89ee1c5358 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -17,9 +17,9 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.88.0", + "sass": "1.91.0", "sass-loader": "16.0.5", - "webpack": "5.99.8", + "webpack": "5.101.3", "webpack-cli": "5.1.4" } } diff --git a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs new file mode 100644 index 0000000000..ed628105e0 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Bit.Api.Vault.AuthorizationHandlers.Collections; +using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.Api.AdminConsole.Authorization; + +public static class AuthorizationHandlerCollectionExtensions +{ + public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection services) + { + services.TryAddScoped(); + + services.TryAddEnumerable([ + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ]); + } +} diff --git a/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs index accb9539fa..5cb261b41d 100644 --- a/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs +++ b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; diff --git a/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs index e21d153bab..9ea01bd21b 100644 --- a/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs +++ b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs @@ -1,9 +1,7 @@ -#nullable enable - -using System.Security.Claims; +using System.Security.Claims; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Models.Data; namespace Bit.Api.AdminConsole.Authorization; diff --git a/src/Api/AdminConsole/Authorization/OrganizationContext.cs b/src/Api/AdminConsole/Authorization/OrganizationContext.cs new file mode 100644 index 0000000000..7b06e33dfd --- /dev/null +++ b/src/Api/AdminConsole/Authorization/OrganizationContext.cs @@ -0,0 +1,84 @@ +using System.Security.Claims; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Bit.Core.Services; + +// Note: do not move this into Core! See remarks below. +namespace Bit.Api.AdminConsole.Authorization; + +/// +/// Provides information about a user's membership or provider relationship with an organization. +/// Used for authorization decisions in the API layer, usually called by a controller or authorization handler or attribute. +/// +/// +/// This is intended to deprecate organization-related methods in . +/// It should remain in the API layer (not Core) because it is closely tied to user claims and authentication. +/// +public interface IOrganizationContext +{ + /// + /// Parses the provided for claims relating to the specified organization. + /// A user will have organization claims if they are a confirmed member of the organization. + /// + /// The claims for the user. + /// The organization to extract claims for. + /// + /// A representing the user's claims for the organization, + /// or null if the user has no claims. + /// + public CurrentContextOrganization? GetOrganizationClaims(ClaimsPrincipal user, Guid organizationId); + /// + /// Used to determine whether the user is a ProviderUser for the specified organization. + /// + /// The claims for the user. + /// The organization to check the provider relationship for. + /// True if the user is a ProviderUser for the specified organization, otherwise false. + /// + /// This requires a database call, but the results are cached for the lifetime of the service instance. + /// Try to check purely claims-based sources of authorization first (such as organization membership with + /// ) to avoid unnecessary database calls. + /// + public Task IsProviderUserForOrganization(ClaimsPrincipal user, Guid organizationId); +} + +public class OrganizationContext( + IUserService userService, + IProviderUserRepository providerUserRepository) : IOrganizationContext +{ + public const string NoUserIdError = "This method should only be called on the private api with a logged in user."; + + /// + /// Caches provider relationships by UserId. + /// In practice this should only have 1 entry (for the current user), but this approach ensures that a mix-up + /// between users cannot occur if is called with a different + /// ClaimsPrincipal for any reason. + /// + private readonly Dictionary> _providerUserOrganizationsCache = new(); + + public CurrentContextOrganization? GetOrganizationClaims(ClaimsPrincipal user, Guid organizationId) + { + return user.GetCurrentContextOrganization(organizationId); + } + + public async Task IsProviderUserForOrganization(ClaimsPrincipal user, Guid organizationId) + { + var userId = userService.GetProperUserId(user); + if (!userId.HasValue) + { + throw new InvalidOperationException(NoUserIdError); + } + + if (!_providerUserOrganizationsCache.TryGetValue(userId.Value, out var providerUserOrganizations)) + { + providerUserOrganizations = + await providerUserRepository.GetManyOrganizationDetailsByUserAsync(userId.Value, + ProviderUserStatusType.Confirmed); + providerUserOrganizations = providerUserOrganizations.ToList(); + _providerUserOrganizationsCache[userId.Value] = providerUserOrganizations; + } + + return providerUserOrganizations.Any(o => o.OrganizationId == organizationId); + } +} diff --git a/src/Api/AdminConsole/Authorization/Requirements/BasePermissionRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/BasePermissionRequirement.cs new file mode 100644 index 0000000000..e904080043 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Requirements/BasePermissionRequirement.cs @@ -0,0 +1,24 @@ +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Data; + +namespace Bit.Api.AdminConsole.Authorization.Requirements; + +/// +/// A base implementation of which will authorize Owners, Admins, Providers, +/// and custom users with the permission specified by the permissionPicker constructor parameter. This is suitable +/// for most requirements related to a custom permission. +/// +/// A function that returns a custom permission which will authorize the action. +public abstract class BasePermissionRequirement(Func permissionPicker) : IOrganizationRequirement +{ + public async Task AuthorizeAsync(CurrentContextOrganization? organizationClaims, + Func> isProviderUserForOrg) + => organizationClaims switch + { + { Type: OrganizationUserType.Owner } => true, + { Type: OrganizationUserType.Admin } => true, + { Type: OrganizationUserType.Custom } when permissionPicker(organizationClaims.Permissions) => true, + _ => await isProviderUserForOrg() + }; +} diff --git a/src/Api/AdminConsole/Authorization/Requirements/ManageAccountRecoveryRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/ManageAccountRecoveryRequirement.cs deleted file mode 100644 index 268fee5d95..0000000000 --- a/src/Api/AdminConsole/Authorization/Requirements/ManageAccountRecoveryRequirement.cs +++ /dev/null @@ -1,20 +0,0 @@ -#nullable enable - -using Bit.Core.Context; -using Bit.Core.Enums; - -namespace Bit.Api.AdminConsole.Authorization.Requirements; - -public class ManageAccountRecoveryRequirement : IOrganizationRequirement -{ - public async Task AuthorizeAsync( - CurrentContextOrganization? organizationClaims, - Func> isProviderUserForOrg) - => organizationClaims switch - { - { Type: OrganizationUserType.Owner } => true, - { Type: OrganizationUserType.Admin } => true, - { Permissions.ManageResetPassword: true } => true, - _ => await isProviderUserForOrg() - }; -} diff --git a/src/Api/AdminConsole/Authorization/Requirements/ManageGroupsOrUsersRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/ManageGroupsOrUsersRequirement.cs new file mode 100644 index 0000000000..daa5c025cb --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Requirements/ManageGroupsOrUsersRequirement.cs @@ -0,0 +1,17 @@ +using Bit.Core.Context; +using Bit.Core.Enums; + +namespace Bit.Api.AdminConsole.Authorization.Requirements; + +public class ManageGroupsOrUsersRequirement : IOrganizationRequirement +{ + public async Task AuthorizeAsync(CurrentContextOrganization? organizationClaims, Func> isProviderUserForOrg) => + organizationClaims switch + { + { Type: OrganizationUserType.Owner } => true, + { Type: OrganizationUserType.Admin } => true, + { Permissions.ManageGroups: true } => true, + { Permissions.ManageUsers: true } => true, + _ => await isProviderUserForOrg() + }; +} diff --git a/src/Api/AdminConsole/Authorization/Requirements/PermissionRequirements.cs b/src/Api/AdminConsole/Authorization/Requirements/PermissionRequirements.cs new file mode 100644 index 0000000000..e3100aff11 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Requirements/PermissionRequirements.cs @@ -0,0 +1,11 @@ +namespace Bit.Api.AdminConsole.Authorization.Requirements; + +public class AccessEventLogsRequirement() : BasePermissionRequirement(p => p.AccessEventLogs); +public class AccessImportExportRequirement() : BasePermissionRequirement(p => p.AccessImportExport); +public class AccessReportsRequirement() : BasePermissionRequirement(p => p.AccessReports); +public class ManageAccountRecoveryRequirement() : BasePermissionRequirement(p => p.ManageResetPassword); +public class ManageGroupsRequirement() : BasePermissionRequirement(p => p.ManageGroups); +public class ManagePoliciesRequirement() : BasePermissionRequirement(p => p.ManagePolicies); +public class ManageScimRequirement() : BasePermissionRequirement(p => p.ManageScim); +public class ManageSsoRequirement() : BasePermissionRequirement(p => p.ManageSso); +public class ManageUsersRequirement() : BasePermissionRequirement(p => p.ManageUsers); diff --git a/src/Api/AdminConsole/Controllers/EventsController.cs b/src/Api/AdminConsole/Controllers/EventsController.cs index 921ee84400..f868f0b3b6 100644 --- a/src/Api/AdminConsole/Controllers/EventsController.cs +++ b/src/Api/AdminConsole/Controllers/EventsController.cs @@ -1,10 +1,16 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Context; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Entities; +using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Repositories; using Microsoft.AspNetCore.Authorization; @@ -22,6 +28,10 @@ public class EventsController : Controller private readonly IProviderUserRepository _providerUserRepository; private readonly IEventRepository _eventRepository; private readonly ICurrentContext _currentContext; + private readonly ISecretRepository _secretRepository; + private readonly IProjectRepository _projectRepository; + private readonly IServiceAccountRepository _serviceAccountRepository; + public EventsController( IUserService userService, @@ -29,7 +39,10 @@ public class EventsController : Controller IOrganizationUserRepository organizationUserRepository, IProviderUserRepository providerUserRepository, IEventRepository eventRepository, - ICurrentContext currentContext) + ICurrentContext currentContext, + ISecretRepository secretRepository, + IProjectRepository projectRepository, + IServiceAccountRepository serviceAccountRepository) { _userService = userService; _cipherRepository = cipherRepository; @@ -37,6 +50,9 @@ public class EventsController : Controller _providerUserRepository = providerUserRepository; _eventRepository = eventRepository; _currentContext = currentContext; + _secretRepository = secretRepository; + _projectRepository = projectRepository; + _serviceAccountRepository = serviceAccountRepository; } [HttpGet("")] @@ -101,6 +117,128 @@ public class EventsController : Controller return new ListResponseModel(responses, result.ContinuationToken); } + [HttpGet("~/organization/{orgId}/secrets/{id}/events")] + public async Task> GetSecrets( + Guid id, Guid orgId, + [FromQuery] DateTime? start = null, + [FromQuery] DateTime? end = null, + [FromQuery] string continuationToken = null) + { + if (id == Guid.Empty || orgId == Guid.Empty) + { + throw new NotFoundException(); + } + + var secret = await _secretRepository.GetByIdAsync(id); + var orgIdForVerification = secret?.OrganizationId ?? orgId; + var secretOrg = _currentContext.GetOrganization(orgIdForVerification); + + if (secretOrg == null || !await _currentContext.AccessEventLogs(secretOrg.Id)) + { + throw new NotFoundException(); + } + + bool canViewLogs = false; + + if (secret == null) + { + secret = new Core.SecretsManager.Entities.Secret { Id = id, OrganizationId = orgId }; + canViewLogs = secretOrg.Type is Core.Enums.OrganizationUserType.Admin or Core.Enums.OrganizationUserType.Owner; + } + else + { + canViewLogs = await CanViewSecretsLogs(secret); + } + + if (!canViewLogs) + { + throw new NotFoundException(); + } + + var (fromDate, toDate) = ApiHelpers.GetDateRange(start, end); + var result = await _eventRepository.GetManyBySecretAsync(secret, fromDate, toDate, new PageOptions { ContinuationToken = continuationToken }); + var responses = result.Data.Select(e => new EventResponseModel(e)); + return new ListResponseModel(responses, result.ContinuationToken); + } + + [HttpGet("~/organization/{orgId}/projects/{id}/events")] + public async Task> GetProjects( + Guid id, + Guid orgId, + [FromQuery] DateTime? start = null, + [FromQuery] DateTime? end = null, + [FromQuery] string continuationToken = null) + { + if (id == Guid.Empty || orgId == Guid.Empty) + { + throw new NotFoundException(); + } + + var project = await GetProject(id, orgId); + await ValidateOrganization(project); + + var (fromDate, toDate) = ApiHelpers.GetDateRange(start, end); + var result = await _eventRepository.GetManyByProjectAsync( + project, + fromDate, + toDate, + new PageOptions { ContinuationToken = continuationToken }); + + var responses = result.Data.Select(e => new EventResponseModel(e)); + return new ListResponseModel(responses, result.ContinuationToken); + } + + [HttpGet("~/organization/{orgId}/service-account/{id}/events")] + public async Task> GetServiceAccounts( + Guid orgId, + Guid id, + [FromQuery] DateTime? start = null, + [FromQuery] DateTime? end = null, + [FromQuery] string continuationToken = null) + { + if (id == Guid.Empty || orgId == Guid.Empty) + { + throw new NotFoundException(); + } + + var serviceAccount = await GetServiceAccount(id, orgId); + var org = _currentContext.GetOrganization(orgId); + + if (org == null || !await _currentContext.AccessEventLogs(org.Id)) + { + throw new NotFoundException(); + } + + var (fromDate, toDate) = ApiHelpers.GetDateRange(start, end); + var result = await _eventRepository.GetManyByOrganizationServiceAccountAsync( + serviceAccount.OrganizationId, + serviceAccount.Id, + fromDate, + toDate, + new PageOptions { ContinuationToken = continuationToken }); + + var responses = result.Data.Select(e => new EventResponseModel(e)); + return new ListResponseModel(responses, result.ContinuationToken); + } + + [ApiExplorerSettings(IgnoreApi = true)] + private async Task GetServiceAccount(Guid serviceAccountId, Guid orgId) + { + var serviceAccount = await _serviceAccountRepository.GetByIdAsync(serviceAccountId); + if (serviceAccount != null) + { + return serviceAccount; + } + + var fallbackServiceAccount = new ServiceAccount + { + Id = serviceAccountId, + OrganizationId = orgId + }; + + return fallbackServiceAccount; + } + [HttpGet("~/organizations/{orgId}/users/{id}/events")] public async Task> GetOrganizationUser(string orgId, string id, [FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null) @@ -154,4 +292,48 @@ public class EventsController : Controller var responses = result.Data.Select(e => new EventResponseModel(e)); return new ListResponseModel(responses, result.ContinuationToken); } + + [ApiExplorerSettings(IgnoreApi = true)] + private async Task ValidateOrganization(Project project) + { + var org = _currentContext.GetOrganization(project.OrganizationId); + + if (org == null || !await _currentContext.AccessEventLogs(org.Id)) + { + throw new NotFoundException(); + } + } + + [ApiExplorerSettings(IgnoreApi = true)] + private async Task GetProject(Guid projectGuid, Guid orgGuid) + { + var project = await _projectRepository.GetByIdAsync(projectGuid); + if (project != null) + { + return project; + } + + var fallbackProject = new Project + { + Id = projectGuid, + OrganizationId = orgGuid + }; + + return fallbackProject; + } + + [ApiExplorerSettings(IgnoreApi = true)] + private async Task CanViewSecretsLogs(Secret secret) + { + if (!_currentContext.AccessSecretsManager(secret.OrganizationId)) + { + throw new NotFoundException(); + } + + var userId = _userService.GetProperUserId(User)!.Value; + var isAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, isAdmin); + var access = await _secretRepository.AccessToSecretAsync(secret.Id, userId, accessClient); + return access.Read; + } } diff --git a/src/Api/AdminConsole/Controllers/GroupsController.cs b/src/Api/AdminConsole/Controllers/GroupsController.cs index 946d7399c2..4587e54aee 100644 --- a/src/Api/AdminConsole/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Controllers/GroupsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response; using Bit.Api.Models.Response; using Bit.Api.Vault.AuthorizationHandlers.Collections; @@ -160,7 +163,6 @@ public class GroupsController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(Guid orgId, Guid id, [FromBody] GroupRequestModel model) { if (!await _currentContext.ManageGroups(orgId)) @@ -234,8 +236,14 @@ public class GroupsController : Controller return new GroupResponseModel(group); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + public async Task PostPut(Guid orgId, Guid id, [FromBody] GroupRequestModel model) + { + return await Put(orgId, id, model); + } + [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(string orgId, string id) { var group = await _groupRepository.GetByIdAsync(new Guid(id)); @@ -247,8 +255,14 @@ public class GroupsController : Controller await _deleteGroupCommand.DeleteAsync(group); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(string orgId, string id) + { + await Delete(orgId, id); + } + [HttpDelete("")] - [HttpPost("delete")] public async Task BulkDelete([FromBody] GroupBulkRequestModel model) { var groups = await _groupRepository.GetManyByManyIds(model.Ids); @@ -264,9 +278,15 @@ public class GroupsController : Controller await _deleteGroupCommand.DeleteManyAsync(groups); } + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostBulkDelete([FromBody] GroupBulkRequestModel model) + { + await BulkDelete(model); + } + [HttpDelete("{id}/user/{orgUserId}")] - [HttpPost("{id}/delete-user/{orgUserId}")] - public async Task Delete(string orgId, string id, string orgUserId) + public async Task DeleteUser(string orgId, string id, string orgUserId) { var group = await _groupRepository.GetByIdAsync(new Guid(id)); if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) @@ -276,4 +296,11 @@ public class GroupsController : Controller await _groupService.DeleteUserAsync(group, new Guid(orgUserId)); } + + [HttpPost("{id}/delete-user/{orgUserId}")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteUser(string orgId, string id, string orgUserId) + { + await DeleteUser(orgId, id, orgUserId); + } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs b/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs index 8e54bfca9c..776e28d2a3 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs @@ -1,14 +1,17 @@ -using Bit.Api.AdminConsole.Models.Request.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -137,7 +140,6 @@ public class OrganizationConnectionsController : Controller } [HttpDelete("{organizationConnectionId}")] - [HttpPost("{organizationConnectionId}/delete")] public async Task DeleteConnection(Guid organizationConnectionId) { var connection = await _organizationConnectionRepository.GetByIdAsync(organizationConnectionId); @@ -155,6 +157,13 @@ public class OrganizationConnectionsController : Controller await _deleteOrganizationConnectionCommand.DeleteAsync(connection); } + [HttpPost("{organizationConnectionId}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteConnection(Guid organizationConnectionId) + { + await DeleteConnection(organizationConnectionId); + } + private async Task> GetConnectionsAsync(Guid organizationId, OrganizationConnectionType type) => await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organizationId, type); diff --git a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs index a8882dfaf3..15cfafe240 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs @@ -46,7 +46,7 @@ public class OrganizationDomainController : Controller } [HttpGet("{orgId}/domain")] - public async Task> Get(Guid orgId) + public async Task> GetAll(Guid orgId) { await ValidateOrganizationAccessAsync(orgId); @@ -105,7 +105,6 @@ public class OrganizationDomainController : Controller } [HttpDelete("{orgId}/domain/{id}")] - [HttpPost("{orgId}/domain/{id}/remove")] public async Task RemoveDomain(Guid orgId, Guid id) { await ValidateOrganizationAccessAsync(orgId); @@ -119,6 +118,13 @@ public class OrganizationDomainController : Controller await _deleteOrganizationDomainCommand.DeleteAsync(domain); } + [HttpPost("{orgId}/domain/{id}/remove")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostRemoveDomain(Guid orgId, Guid id) + { + await RemoveDomain(orgId, id); + } + [AllowAnonymous] [HttpPost("domain/sso/details")] // must be post to accept email cleanly public async Task GetOrgDomainSsoDetails( diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs index 848098ef00..ae0f91d355 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs @@ -18,6 +18,27 @@ public class OrganizationIntegrationConfigurationController( IOrganizationIntegrationRepository integrationRepository, IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller { + [HttpGet("")] + public async Task> GetAsync( + Guid organizationId, + Guid integrationId) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var configurations = await integrationConfigurationRepository.GetManyByIntegrationAsync(integrationId); + return configurations + .Select(configuration => new OrganizationIntegrationConfigurationResponseModel(configuration)) + .ToList(); + } + [HttpPost("")] public async Task CreateAsync( Guid organizationId, @@ -77,7 +98,6 @@ public class OrganizationIntegrationConfigurationController( } [HttpDelete("{configurationId:guid}")] - [HttpPost("{configurationId:guid}/delete")] public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) { if (!await HasPermission(organizationId)) @@ -99,6 +119,13 @@ public class OrganizationIntegrationConfigurationController( await integrationConfigurationRepository.DeleteAsync(configuration); } + [HttpPost("{configurationId:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) + { + await DeleteAsync(organizationId, integrationId, configurationId); + } + private async Task HasPermission(Guid organizationId) { return await currentContext.OrganizationOwner(organizationId); diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs index 3b52e7a8da..a12492949d 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs @@ -19,6 +19,20 @@ public class OrganizationIntegrationController( ICurrentContext currentContext, IOrganizationIntegrationRepository integrationRepository) : Controller { + [HttpGet("")] + public async Task> GetAsync(Guid organizationId) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + + var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId); + return integrations + .Select(integration => new OrganizationIntegrationResponseModel(integration)) + .ToList(); + } + [HttpPost("")] public async Task CreateAsync(Guid organizationId, [FromBody] OrganizationIntegrationRequestModel model) { @@ -50,7 +64,6 @@ public class OrganizationIntegrationController( } [HttpDelete("{integrationId:guid}")] - [HttpPost("{integrationId:guid}/delete")] public async Task DeleteAsync(Guid organizationId, Guid integrationId) { if (!await HasPermission(organizationId)) @@ -67,6 +80,13 @@ public class OrganizationIntegrationController( await integrationRepository.DeleteAsync(integration); } + [HttpPost("{integrationId:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteAsync(Guid organizationId, Guid integrationId) + { + await DeleteAsync(organizationId, integrationId); + } + private async Task HasPermission(Guid organizationId) { return await currentContext.OrganizationOwner(organizationId); diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 7765eb2665..74ac9b1255 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -1,4 +1,8 @@ -using Bit.Api.AdminConsole.Authorization.Requirements; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Request.Organizations; @@ -7,20 +11,20 @@ using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Api; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; @@ -55,19 +59,19 @@ public class OrganizationUsersController : Controller private readonly IApplicationCacheService _applicationCacheService; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; - private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand; private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; + private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; + private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand; - public OrganizationUsersController( - IOrganizationRepository organizationRepository, + public OrganizationUsersController(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationService organizationService, ICollectionRepository collectionRepository, @@ -83,7 +87,6 @@ public class OrganizationUsersController : Controller IApplicationCacheService applicationCacheService, ISsoConfigRepository ssoConfigRepository, IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, - ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, @@ -92,7 +95,9 @@ public class OrganizationUsersController : Controller IPricingClient pricingClient, IConfirmOrganizationUserCommand confirmOrganizationUserCommand, IRestoreOrganizationUserCommand restoreOrganizationUserCommand, - IInitPendingOrganizationCommand initPendingOrganizationCommand) + IInitPendingOrganizationCommand initPendingOrganizationCommand, + IRevokeOrganizationUserCommand revokeOrganizationUserCommand, + IResendOrganizationInviteCommand resendOrganizationInviteCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -110,23 +115,25 @@ public class OrganizationUsersController : Controller _applicationCacheService = applicationCacheService; _ssoConfigRepository = ssoConfigRepository; _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; - _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; _deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand; _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; _pricingClient = pricingClient; + _resendOrganizationInviteCommand = resendOrganizationInviteCommand; _confirmOrganizationUserCommand = confirmOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; _initPendingOrganizationCommand = initPendingOrganizationCommand; + _revokeOrganizationUserCommand = revokeOrganizationUserCommand; } [HttpGet("{id}")] - public async Task Get(Guid id, bool includeGroups = false) + [Authorize] + public async Task Get(Guid orgId, Guid id, bool includeGroups = false) { var (organizationUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id); - if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.OrganizationId)) + if (organizationUser == null || organizationUser.OrganizationId != orgId) { throw new NotFoundException(); } @@ -145,66 +152,30 @@ public class OrganizationUsersController : Controller return response; } + /// + /// Returns a set of basic information about all members of the organization. This is available to all members of + /// the organization to manage collections. For this reason, it contains as little information as possible and no + /// cryptographic keys or other sensitive data. + /// + /// Organization identifier + /// List of users for the organization. [HttpGet("mini-details")] + [Authorize] public async Task> GetMiniDetails(Guid orgId) { - var authorizationResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), - OrganizationUserUserMiniDetailsOperations.ReadAll); - if (!authorizationResult.Succeeded) - { - throw new NotFoundException(); - } - var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgId); return new ListResponseModel( organizationUserUserDetails.Select(ou => new OrganizationUserUserMiniDetailsResponseModel(ou))); } [HttpGet("")] - public async Task> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false) - { - - if (_featureService.IsEnabled(FeatureFlagKeys.SeparateCustomRolePermissions)) - { - return await GetvNextAsync(orgId, includeGroups, includeCollections); - } - - var authorized = (await _authorizationService.AuthorizeAsync( - User, new OrganizationScope(orgId), OrganizationUserUserDetailsOperations.ReadAll)).Succeeded; - if (!authorized) - { - throw new NotFoundException(); - } - - var organizationUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails( - new OrganizationUserUserDetailsQueryRequest - { - OrganizationId = orgId, - IncludeGroups = includeGroups, - IncludeCollections = includeCollections - } - ); - var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); - var organizationUsersClaimedStatus = await GetClaimedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id)); - var responses = organizationUsers - .Select(o => - { - var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled; - var claimedByOrganization = organizationUsersClaimedStatus[o.Id]; - var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, claimedByOrganization); - - return orgUser; - }); - return new ListResponseModel(responses); - } - - private async Task> GetvNextAsync(Guid orgId, bool includeGroups = false, bool includeCollections = false) + public async Task> GetAll(Guid orgId, bool includeGroups = false, bool includeCollections = false) { var request = new OrganizationUserUserDetailsQueryRequest { OrganizationId = orgId, IncludeGroups = includeGroups, - IncludeCollections = includeCollections, + IncludeCollections = includeCollections }; if ((await _authorizationService.AuthorizeAsync(User, new ManageUsersRequirement())).Succeeded) @@ -228,34 +199,12 @@ public class OrganizationUsersController : Controller .ToList()); } - - [HttpGet("{id}/groups")] - public async Task> GetGroups(string orgId, string id) - { - var organizationUser = await _organizationUserRepository.GetByIdAsync(new Guid(id)); - if (organizationUser == null || (!await _currentContext.ManageGroups(organizationUser.OrganizationId) && - !await _currentContext.ManageUsers(organizationUser.OrganizationId))) - { - throw new NotFoundException(); - } - - var groupIds = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Id); - var responses = groupIds.Select(g => g.ToString()); - return responses; - } - [HttpGet("{id}/reset-password-details")] - public async Task GetResetPasswordDetails(string orgId, string id) + [Authorize] + public async Task GetResetPasswordDetails(Guid orgId, Guid id) { - // Make sure the calling user can reset passwords for this org - var orgGuidId = new Guid(orgId); - if (!await _currentContext.ManageResetPassword(orgGuidId)) - { - throw new NotFoundException(); - } - - var organizationUser = await _organizationUserRepository.GetByIdAsync(new Guid(id)); - if (organizationUser == null || !organizationUser.UserId.HasValue) + var organizationUser = await _organizationUserRepository.GetByIdAsync(id); + if (organizationUser is null || organizationUser.UserId is null) { throw new NotFoundException(); } @@ -269,7 +218,7 @@ public class OrganizationUsersController : Controller } // Retrieve Encrypted Private Key from organization - var org = await _organizationRepository.GetByIdAsync(orgGuidId); + var org = await _organizationRepository.GetByIdAsync(orgId); if (org == null) { throw new NotFoundException(); @@ -279,26 +228,17 @@ public class OrganizationUsersController : Controller } [HttpPost("account-recovery-details")] + [Authorize] public async Task> GetAccountRecoveryDetails(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - // Make sure the calling user can reset passwords for this org - if (!await _currentContext.ManageResetPassword(orgId)) - { - throw new NotFoundException(); - } - var responses = await _organizationUserRepository.GetManyAccountRecoveryDetailsByOrganizationUserAsync(orgId, model.Ids); return new ListResponseModel(responses.Select(r => new OrganizationUserResetPasswordDetailsResponseModel(r))); } [HttpPost("invite")] + [Authorize] public async Task Invite(Guid orgId, [FromBody] OrganizationUserInviteRequestModel model) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - // Check the user has permission to grant access to the collections for the new user if (model.Collections?.Any() == true) { @@ -314,35 +254,25 @@ public class OrganizationUsersController : Controller var userId = _userService.GetProperUserId(User); await _organizationService.InviteUsersAsync(orgId, userId.Value, systemUser: null, - new (OrganizationUserInvite, string)[] { (new OrganizationUserInvite(model.ToData()), null) }); + [(new OrganizationUserInvite(model.ToData()), null)]); } [HttpPost("reinvite")] - public async Task> BulkReinvite(string orgId, [FromBody] OrganizationUserBulkRequestModel model) + [Authorize] + public async Task> BulkReinvite(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - var orgGuidId = new Guid(orgId); - if (!await _currentContext.ManageUsers(orgGuidId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); - var result = await _organizationService.ResendInvitesAsync(orgGuidId, userId.Value, model.Ids); + var result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids); return new ListResponseModel( result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2))); } [HttpPost("{id}/reinvite")] - public async Task Reinvite(string orgId, string id) + [Authorize] + public async Task Reinvite(Guid orgId, Guid id) { - var orgGuidId = new Guid(orgId); - if (!await _currentContext.ManageUsers(orgGuidId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); - await _organizationService.ResendInviteAsync(orgGuidId, userId.Value, new Guid(id)); + await _resendOrganizationInviteCommand.ResendInviteAsync(orgId, userId.Value, id); } [HttpPost("{organizationUserId}/accept-init")] @@ -403,57 +333,38 @@ public class OrganizationUsersController : Controller } [HttpPost("{id}/confirm")] + [Authorize] public async Task Confirm(Guid orgId, Guid id, [FromBody] OrganizationUserConfirmRequestModel model) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); - var result = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, id, model.Key, userId.Value, model.DefaultUserCollectionName); + _ = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, id, model.Key, userId.Value, model.DefaultUserCollectionName); } [HttpPost("confirm")] - public async Task> BulkConfirm(string orgId, + [Authorize] + public async Task> BulkConfirm(Guid orgId, [FromBody] OrganizationUserBulkConfirmRequestModel model) { - var orgGuidId = new Guid(orgId); - if (!await _currentContext.ManageUsers(orgGuidId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); - var results = await _confirmOrganizationUserCommand.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value); + var results = await _confirmOrganizationUserCommand.ConfirmUsersAsync(orgId, model.ToDictionary(), userId.Value, model.DefaultUserCollectionName); return new ListResponseModel(results.Select(r => new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); } [HttpPost("public-keys")] - public async Task> UserPublicKeys(string orgId, [FromBody] OrganizationUserBulkRequestModel model) + [Authorize] + public async Task> UserPublicKeys(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - var orgGuidId = new Guid(orgId); - if (!await _currentContext.ManageUsers(orgGuidId)) - { - throw new NotFoundException(); - } - - var result = await _organizationUserRepository.GetManyPublicKeysByOrganizationUserAsync(orgGuidId, model.Ids); + var result = await _organizationUserRepository.GetManyPublicKeysByOrganizationUserAsync(orgId, model.Ids); var responses = result.Select(r => new OrganizationUserPublicKeyResponseModel(r.Id, r.UserId, r.PublicKey)).ToList(); return new ListResponseModel(responses); } [HttpPut("{id}")] - [HttpPost("{id}")] + [Authorize] public async Task Put(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var (organizationUser, currentAccess) = await _organizationUserRepository.GetByIdWithCollectionsAsync(id); if (organizationUser == null || organizationUser.OrganizationId != orgId) { @@ -526,6 +437,14 @@ public class OrganizationUsersController : Controller collectionsToSave, groupsToSave); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task PostPut(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model) + { + await Put(orgId, id, model); + } + [HttpPut("{userId}/reset-password-enrollment")] public async Task PutResetPasswordEnrollment(Guid orgId, Guid userId, [FromBody] OrganizationUserResetPasswordEnrollmentRequestModel model) { @@ -554,27 +473,19 @@ public class OrganizationUsersController : Controller } [HttpPut("{id}/reset-password")] - public async Task PutResetPassword(string orgId, string id, [FromBody] OrganizationUserResetPasswordRequestModel model) + [Authorize] + public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) { - - var orgGuidId = new Guid(orgId); - - // Calling user must have Manage Reset Password permission - if (!await _currentContext.ManageResetPassword(orgGuidId)) - { - throw new NotFoundException(); - } - // Get the users role, since provider users aren't a member of the organization we use the owner check - var orgUserType = await _currentContext.OrganizationOwner(orgGuidId) + var orgUserType = await _currentContext.OrganizationOwner(orgId) ? OrganizationUserType.Owner - : _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgGuidId)?.Type; + : _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type; if (orgUserType == null) { throw new NotFoundException(); } - var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgGuidId, new Guid(id), model.NewMasterPasswordHash, model.Key); + var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key); if (result.Succeeded) { return; @@ -590,110 +501,160 @@ public class OrganizationUsersController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/remove")] + [Authorize] public async Task Remove(Guid orgId, Guid id) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); await _removeOrganizationUserCommand.RemoveUserAsync(orgId, id, userId.Value); } + [HttpPost("{id}/remove")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + [Authorize] + public async Task PostRemove(Guid orgId, Guid id) + { + await Remove(orgId, id); + } + [HttpDelete("")] - [HttpPost("remove")] + [Authorize] public async Task> BulkRemove(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); var result = await _removeOrganizationUserCommand.RemoveUsersAsync(orgId, model.Ids, userId.Value); return new ListResponseModel(result.Select(r => new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); } - [HttpDelete("{id}/delete-account")] - [HttpPost("{id}/delete-account")] - public async Task DeleteAccount(Guid orgId, Guid id) + [HttpPost("remove")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + [Authorize] + public async Task> PostBulkRemove(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - if (!await _currentContext.ManageUsers(orgId)) + return await BulkRemove(orgId, model); + } + + [HttpDelete("{id}/delete-account")] + [Authorize] + public async Task DeleteAccount(Guid orgId, Guid id) + { + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) { - throw new NotFoundException(); + return TypedResults.Unauthorized(); } - var currentUser = await _userService.GetUserByPrincipalAsync(User); - if (currentUser == null) - { - throw new UnauthorizedAccessException(); - } + var commandResult = await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUserId.Value); - await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); + return commandResult.Result.Match( + error => error is NotFoundError + ? TypedResults.NotFound(new ErrorResponseModel(error.Message)) + : TypedResults.BadRequest(new ErrorResponseModel(error.Message)), + TypedResults.Ok + ); + } + + [HttpPost("{id}/delete-account")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + [Authorize] + public async Task PostDeleteAccount(Guid orgId, Guid id) + { + await DeleteAccount(orgId, id); } [HttpDelete("delete-account")] - [HttpPost("delete-account")] + [Authorize] public async Task> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - - var currentUser = await _userService.GetUserByPrincipalAsync(User); - if (currentUser == null) + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) { throw new UnauthorizedAccessException(); } - var results = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); + var result = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUserId.Value); - return new ListResponseModel(results.Select(r => - new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); + var responses = result.Select(r => r.Result.Match( + error => new OrganizationUserBulkResponseModel(r.Id, error.Message), + _ => new OrganizationUserBulkResponseModel(r.Id, string.Empty) + )); + + return new ListResponseModel(responses); + } + + [HttpPost("delete-account")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + [Authorize] + public async Task> PostBulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + return await BulkDeleteAccount(orgId, model); + } + + [HttpPut("{id}/revoke")] + [Authorize] + public async Task RevokeAsync(Guid orgId, Guid id) + { + await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync); } [HttpPatch("{id}/revoke")] - [HttpPut("{id}/revoke")] - public async Task RevokeAsync(Guid orgId, Guid id) + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task PatchRevokeAsync(Guid orgId, Guid id) { - await RestoreOrRevokeUserAsync(orgId, id, _organizationService.RevokeUserAsync); + await RevokeAsync(orgId, id); + } + + [HttpPut("revoke")] + [Authorize] + public async Task> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); } [HttpPatch("revoke")] - [HttpPut("revoke")] - public async Task> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task> PatchBulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - return await RestoreOrRevokeUsersAsync(orgId, model, _organizationService.RevokeUsersAsync); + return await BulkRevokeAsync(orgId, model); } - [HttpPatch("{id}/restore")] [HttpPut("{id}/restore")] + [Authorize] public async Task RestoreAsync(Guid orgId, Guid id) { await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId)); } - [HttpPatch("restore")] + [HttpPatch("{id}/restore")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task PatchRestoreAsync(Guid orgId, Guid id) + { + await RestoreAsync(orgId, id); + } + [HttpPut("restore")] + [Authorize] public async Task> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); } - [HttpPatch("enable-secrets-manager")] + [HttpPatch("restore")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task> PatchBulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + return await BulkRestoreAsync(orgId, model); + } + [HttpPut("enable-secrets-manager")] + [Authorize] public async Task BulkEnableSecretsManagerAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var orgUsers = (await _organizationUserRepository.GetManyAsync(model.Ids)) .Where(ou => ou.OrganizationId == orgId && !ou.AccessSecretsManager).ToList(); if (orgUsers.Count == 0) @@ -721,16 +682,20 @@ public class OrganizationUsersController : Controller await _organizationUserRepository.ReplaceManyAsync(orgUsers); } + [HttpPatch("enable-secrets-manager")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task PatchBulkEnableSecretsManagerAsync(Guid orgId, + [FromBody] OrganizationUserBulkRequestModel model) + { + await BulkEnableSecretsManagerAsync(orgId, model); + } + private async Task RestoreOrRevokeUserAsync( Guid orgId, Guid id, Func statusAction) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); var orgUser = await _organizationUserRepository.GetByIdAsync(id); if (orgUser == null || orgUser.OrganizationId != orgId) @@ -746,11 +711,6 @@ public class OrganizationUsersController : Controller OrganizationUserBulkRequestModel model, Func, Guid?, Task>>> statusAction) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); var result = await statusAction(orgId, model.Ids, userId.Value); return new ListResponseModel(result.Select(r => diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 0d498beab1..590895665d 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response; using Bit.Api.AdminConsole.Models.Response.Organizations; @@ -9,6 +12,7 @@ using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Core; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; @@ -221,7 +225,6 @@ public class OrganizationsController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(string id, [FromBody] OrganizationUpdateRequestModel model) { var orgIdGuid = new Guid(id); @@ -232,8 +235,7 @@ public class OrganizationsController : Controller throw new NotFoundException(); } - var updateBilling = !_globalSettings.SelfHosted && (model.BusinessName != organization.DisplayBusinessName() || - model.BillingEmail != organization.BillingEmail); + var updateBilling = ShouldUpdateBilling(model, organization); var hasRequiredPermissions = updateBilling ? await _currentContext.EditSubscription(orgIdGuid) @@ -249,6 +251,13 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(organization, plan); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + public async Task PostPut(string id, [FromBody] OrganizationUpdateRequestModel model) + { + return await Put(id, model); + } + [HttpPost("{id}/storage")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostStorage(string id, [FromBody] StorageRequestModel model) @@ -288,7 +297,6 @@ public class OrganizationsController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(string id, [FromBody] SecretVerificationRequestModel model) { var orgIdGuid = new Guid(id); @@ -331,6 +339,13 @@ public class OrganizationsController : Controller await _organizationDeleteCommand.DeleteAsync(organization); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(string id, [FromBody] SecretVerificationRequestModel model) + { + await Delete(id, model); + } + [HttpPost("{id}/delete-recover-token")] [AllowAnonymous] public async Task PostDeleteRecoverToken(Guid id, [FromBody] OrganizationVerifyDeleteRecoverRequestModel model) @@ -551,18 +566,12 @@ public class OrganizationsController : Controller [HttpPut("{id}/collection-management")] public async Task PutCollectionManagement(Guid id, [FromBody] OrganizationCollectionManagementUpdateRequestModel model) { - var organization = await _organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - if (!await _currentContext.OrganizationOwner(id)) { throw new NotFoundException(); } - await _organizationService.UpdateAsync(model.ToOrganization(organization, _featureService), eventType: EventType.Organization_CollectionManagement_Updated); + var organization = await _organizationService.UpdateCollectionManagementSettingsAsync(id, model.ToSettings()); var plan = await _pricingClient.GetPlan(organization.PlanType); return new OrganizationResponseModel(organization, plan); } @@ -579,4 +588,11 @@ public class OrganizationsController : Controller return organization.PlanType; } + + private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization) + { + var organizationNameChanged = model.Name != organization.Name; + var billingEmailChanged = model.BillingEmail != organization.BillingEmail; + return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged); + } } diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 86a1609ee6..ce92321833 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -1,7 +1,13 @@ -using Bit.Api.AdminConsole.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization.Requirements; +using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response.Helpers; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; @@ -27,7 +33,6 @@ namespace Bit.Api.AdminConsole.Controllers; public class PoliciesController : Controller { private readonly ICurrentContext _currentContext; - private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; private readonly IOrganizationRepository _organizationRepository; @@ -46,7 +51,6 @@ public class PoliciesController : Controller GlobalSettings globalSettings, IDataProtectionProvider dataProtectionProvider, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, - IFeatureService featureService, IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IOrganizationRepository organizationRepository, ISavePolicyCommand savePolicyCommand) @@ -60,7 +64,6 @@ public class PoliciesController : Controller "OrganizationServiceDataProtector"); _organizationRepository = organizationRepository; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; - _featureService = featureService; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; _savePolicyCommand = savePolicyCommand; } @@ -87,7 +90,7 @@ public class PoliciesController : Controller } [HttpGet("")] - public async Task> Get(string orgId) + public async Task> GetAll(string orgId) { var orgIdGuid = new Guid(orgId); if (!await _currentContext.ManagePolicies(orgIdGuid)) @@ -209,4 +212,18 @@ public class PoliciesController : Controller var policy = await _savePolicyCommand.SaveAsync(policyUpdate); return new PolicyResponseModel(policy); } + + + [HttpPut("{type}/vnext")] + [RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)] + [Authorize] + public async Task PutVNext(Guid orgId, [FromBody] SavePolicyRequest model) + { + var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, _currentContext); + + var policy = await _savePolicyCommand.VNextSaveAsync(savePolicyRequest); + + return new PolicyResponseModel(policy); + } + } diff --git a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs index f226ba316e..caf2651e16 100644 --- a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Billing.Controllers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Billing.Controllers; using Bit.Api.Billing.Models.Requests; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; diff --git a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs index 12166c836e..11d302ff86 100644 --- a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Request.Providers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request.Providers; using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Providers.Interfaces; @@ -90,7 +93,6 @@ public class ProviderOrganizationsController : Controller } [HttpDelete("{id:guid}")] - [HttpPost("{id:guid}/delete")] public async Task Delete(Guid providerId, Guid id) { if (!_currentContext.ManageProviderOrganizations(providerId)) @@ -109,4 +111,11 @@ public class ProviderOrganizationsController : Controller providerOrganization, organization); } + + [HttpPost("{id:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(Guid providerId, Guid id) + { + await Delete(providerId, id); + } } diff --git a/src/Api/AdminConsole/Controllers/ProviderUsersController.cs b/src/Api/AdminConsole/Controllers/ProviderUsersController.cs index 73639bb1a4..dcf9492605 100644 --- a/src/Api/AdminConsole/Controllers/ProviderUsersController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderUsersController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Request.Providers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request.Providers; using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Models.Business.Provider; @@ -46,7 +49,7 @@ public class ProviderUsersController : Controller } [HttpGet("")] - public async Task> Get(Guid providerId) + public async Task> GetAll(Guid providerId) { if (!_currentContext.ProviderManageUsers(providerId)) { @@ -152,7 +155,6 @@ public class ProviderUsersController : Controller } [HttpPut("{id:guid}")] - [HttpPost("{id:guid}")] public async Task Put(Guid providerId, Guid id, [FromBody] ProviderUserUpdateRequestModel model) { if (!_currentContext.ProviderManageUsers(providerId)) @@ -170,8 +172,14 @@ public class ProviderUsersController : Controller await _providerService.SaveUserAsync(model.ToProviderUser(providerUser), userId.Value); } + [HttpPost("{id:guid}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + public async Task PostPut(Guid providerId, Guid id, [FromBody] ProviderUserUpdateRequestModel model) + { + await Put(providerId, id, model); + } + [HttpDelete("{id:guid}")] - [HttpPost("{id:guid}/delete")] public async Task Delete(Guid providerId, Guid id) { if (!_currentContext.ProviderManageUsers(providerId)) @@ -183,8 +191,14 @@ public class ProviderUsersController : Controller await _providerService.DeleteUsersAsync(providerId, new[] { id }, userId.Value); } + [HttpPost("{id:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(Guid providerId, Guid id) + { + await Delete(providerId, id); + } + [HttpDelete("")] - [HttpPost("delete")] public async Task> BulkDelete(Guid providerId, [FromBody] ProviderUserBulkRequestModel model) { if (!_currentContext.ProviderManageUsers(providerId)) @@ -197,4 +211,11 @@ public class ProviderUsersController : Controller return new ListResponseModel(result.Select(r => new ProviderUserBulkResponseModel(r.Item1.Id, r.Item2))); } + + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task> PostBulkDelete(Guid providerId, [FromBody] ProviderUserBulkRequestModel model) + { + return await BulkDelete(providerId, model); + } } diff --git a/src/Api/AdminConsole/Controllers/ProvidersController.cs b/src/Api/AdminConsole/Controllers/ProvidersController.cs index b6933da0c9..aa87bf9c74 100644 --- a/src/Api/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Api/AdminConsole/Controllers/ProvidersController.cs @@ -1,10 +1,12 @@ -using Bit.Api.AdminConsole.Models.Request.Providers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request.Providers; using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.AspNetCore.Authorization; @@ -50,7 +52,6 @@ public class ProvidersController : Controller } [HttpPut("{id:guid}")] - [HttpPost("{id:guid}")] public async Task Put(Guid id, [FromBody] ProviderUpdateRequestModel model) { if (!_currentContext.ProviderProviderAdmin(id)) @@ -68,6 +69,13 @@ public class ProvidersController : Controller return new ProviderResponseModel(provider); } + [HttpPost("{id:guid}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + public async Task PostPut(Guid id, [FromBody] ProviderUpdateRequestModel model) + { + return await Put(id, model); + } + [HttpPost("{id:guid}/setup")] public async Task Setup(Guid id, [FromBody] ProviderSetupRequestModel model) { @@ -84,22 +92,12 @@ public class ProvidersController : Controller var userId = _userService.GetProperUserId(User).Value; - var taxInfo = new TaxInfo - { - BillingAddressCountry = model.TaxInfo.Country, - BillingAddressPostalCode = model.TaxInfo.PostalCode, - TaxIdNumber = model.TaxInfo.TaxId, - BillingAddressLine1 = model.TaxInfo.Line1, - BillingAddressLine2 = model.TaxInfo.Line2, - BillingAddressCity = model.TaxInfo.City, - BillingAddressState = model.TaxInfo.State - }; - - var tokenizedPaymentSource = model.PaymentSource?.ToDomain(); + var paymentMethod = model.PaymentMethod.ToDomain(); + var billingAddress = model.BillingAddress.ToDomain(); var response = await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key, - taxInfo, tokenizedPaymentSource); + paymentMethod, billingAddress); return new ProviderResponseModel(response); } @@ -117,7 +115,6 @@ public class ProvidersController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(Guid id) { if (!_currentContext.ProviderProviderAdmin(id)) @@ -139,4 +136,11 @@ public class ProvidersController : Controller await _providerService.DeleteAsync(provider); } + + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(Guid id) + { + await Delete(id); + } } diff --git a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs index 3d749d25d7..c8ff4f9f7c 100644 --- a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs @@ -15,25 +15,58 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.AdminConsole.Controllers; [RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] -[Route("organizations/{organizationId:guid}/integrations/slack")] +[Route("organizations")] [Authorize("Application")] public class SlackIntegrationController( ICurrentContext currentContext, IOrganizationIntegrationRepository integrationRepository, - ISlackService slackService) : Controller + ISlackService slackService, + TimeProvider timeProvider) : Controller { - [HttpGet("redirect")] + [HttpGet("{organizationId:guid}/integrations/slack/redirect")] public async Task RedirectAsync(Guid organizationId) { if (!await currentContext.OrganizationOwner(organizationId)) { throw new NotFoundException(); } - string callbackUrl = Url.RouteUrl( - nameof(CreateAsync), - new { organizationId }, - currentContext.HttpContext.Request.Scheme); - var redirectUrl = slackService.GetRedirectUrl(callbackUrl); + + string? callbackUrl = Url.RouteUrl( + routeName: nameof(CreateAsync), + values: null, + protocol: currentContext.HttpContext.Request.Scheme, + host: currentContext.HttpContext.Request.Host.ToUriComponent() + ); + if (string.IsNullOrEmpty(callbackUrl)) + { + throw new BadRequestException("Unable to build callback Url"); + } + + var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId); + var integration = integrations.FirstOrDefault(i => i.Type == IntegrationType.Slack); + + if (integration is null) + { + // No slack integration exists, create Initiated version + integration = await integrationRepository.CreateAsync(new OrganizationIntegration + { + OrganizationId = organizationId, + Type = IntegrationType.Slack, + Configuration = null, + }); + } + else if (integration.Configuration is not null) + { + // A Completed (fully configured) Slack integration already exists, throw to prevent overriding + throw new BadRequestException("There already exists a Slack integration for this organization"); + + } // An Initiated slack integration exits, re-use it and kick off a new OAuth flow + + var state = IntegrationOAuthState.FromIntegration(integration, timeProvider); + var redirectUrl = slackService.GetRedirectUrl( + callbackUrl: callbackUrl, + state: state.ToString() + ); if (string.IsNullOrEmpty(redirectUrl)) { @@ -43,23 +76,42 @@ public class SlackIntegrationController( return Redirect(redirectUrl); } - [HttpGet("create", Name = nameof(CreateAsync))] - public async Task CreateAsync(Guid organizationId, [FromQuery] string code) + [HttpGet("integrations/slack/create", Name = nameof(CreateAsync))] + [AllowAnonymous] + public async Task CreateAsync([FromQuery] string code, [FromQuery] string state) { - if (!await currentContext.OrganizationOwner(organizationId)) + var oAuthState = IntegrationOAuthState.FromString(state: state, timeProvider: timeProvider); + if (oAuthState is null) { throw new NotFoundException(); } - if (string.IsNullOrEmpty(code)) + // Fetch existing Initiated record + var integration = await integrationRepository.GetByIdAsync(oAuthState.IntegrationId); + if (integration is null || + integration.Type != IntegrationType.Slack || + integration.Configuration is not null) { - throw new BadRequestException("Missing code from Slack."); + throw new NotFoundException(); } - string callbackUrl = Url.RouteUrl( - nameof(CreateAsync), - new { organizationId }, - currentContext.HttpContext.Request.Scheme); + // Verify Organization matches hash + if (!oAuthState.ValidateOrg(integration.OrganizationId)) + { + throw new NotFoundException(); + } + + // Fetch token from Slack and store to DB + string? callbackUrl = Url.RouteUrl( + routeName: nameof(CreateAsync), + values: null, + protocol: currentContext.HttpContext.Request.Scheme, + host: currentContext.HttpContext.Request.Host.ToUriComponent() + ); + if (string.IsNullOrEmpty(callbackUrl)) + { + throw new BadRequestException("Unable to build callback Url"); + } var token = await slackService.ObtainTokenViaOAuth(code, callbackUrl); if (string.IsNullOrEmpty(token)) @@ -67,14 +119,10 @@ public class SlackIntegrationController( throw new BadRequestException("Invalid response from Slack."); } - var integration = await integrationRepository.CreateAsync(new OrganizationIntegration - { - OrganizationId = organizationId, - Type = IntegrationType.Slack, - Configuration = JsonSerializer.Serialize(new SlackIntegration(token)), - }); - var location = $"/organizations/{organizationId}/integrations/{integration.Id}"; + integration.Configuration = JsonSerializer.Serialize(new SlackIntegration(token)); + await integrationRepository.UpsertAsync(integration); + var location = $"/organizations/{integration.OrganizationId}/integrations/{integration.Id}"; return Created(location, new OrganizationIntegrationResponseModel(integration)); } } diff --git a/src/Api/AdminConsole/Jobs/OrganizationSubscriptionUpdateJob.cs b/src/Api/AdminConsole/Jobs/OrganizationSubscriptionUpdateJob.cs new file mode 100644 index 0000000000..3a6dbb22f4 --- /dev/null +++ b/src/Api/AdminConsole/Jobs/OrganizationSubscriptionUpdateJob.cs @@ -0,0 +1,35 @@ +using System.Collections.Immutable; +using Bit.Core; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Jobs; +using Bit.Core.Services; +using Quartz; + +namespace Bit.Api.AdminConsole.Jobs; + +public class OrganizationSubscriptionUpdateJob(ILogger logger, + IGetOrganizationSubscriptionsToUpdateQuery query, + IUpdateOrganizationSubscriptionCommand command, + IFeatureService featureService) : BaseJob(logger) +{ + protected override async Task ExecuteJobAsync(IJobExecutionContext _) + { + if (!featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization)) + { + return; + } + + logger.LogInformation("OrganizationSubscriptionUpdateJob - START"); + + var organizationSubscriptionsToUpdate = + (await query.GetOrganizationSubscriptionsToUpdateAsync()) + .ToImmutableList(); + + logger.LogInformation("OrganizationSubscriptionUpdateJob - {numberOfOrganizations} organization(s) to update", + organizationSubscriptionsToUpdate.Count); + + await command.UpdateOrganizationSubscriptionAsync(organizationSubscriptionsToUpdate); + + logger.LogInformation("OrganizationSubscriptionUpdateJob - COMPLETED"); + } +} diff --git a/src/Api/AdminConsole/Models/Request/AdminAuthRequestUpdateRequestModel.cs b/src/Api/AdminConsole/Models/Request/AdminAuthRequestUpdateRequestModel.cs index abcc6fdb74..13c840ced4 100644 --- a/src/Api/AdminConsole/Models/Request/AdminAuthRequestUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/AdminAuthRequestUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request; diff --git a/src/Api/AdminConsole/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs b/src/Api/AdminConsole/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs index 24386341a3..86e058b847 100644 --- a/src/Api/AdminConsole/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.AdminConsole.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.AdminConsole.Models.Request; public class BulkDenyAdminAuthRequestRequestModel { diff --git a/src/Api/AdminConsole/Models/Request/GroupRequestModel.cs b/src/Api/AdminConsole/Models/Request/GroupRequestModel.cs index a6cfb6733b..007b3d3949 100644 --- a/src/Api/AdminConsole/Models/Request/GroupRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/GroupRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Models.Request; using Bit.Core.AdminConsole.Entities; diff --git a/src/Api/AdminConsole/Models/Request/OrganizationAuthRequestUpdateManyRequestModel.cs b/src/Api/AdminConsole/Models/Request/OrganizationAuthRequestUpdateManyRequestModel.cs index 34a45369b2..bd5e647c84 100644 --- a/src/Api/AdminConsole/Models/Request/OrganizationAuthRequestUpdateManyRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/OrganizationAuthRequestUpdateManyRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationAuth.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationAuth.Models; using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request; diff --git a/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs b/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs index 8bf1ebe39a..46b253da31 100644 --- a/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Models.Request; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationConnectionRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationConnectionRequestModel.cs index d7508b78ef..1dbd624cbe 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationConnectionRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationConnectionRequestModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationConnections; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs index e18122fd2b..7754c44c8c 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs @@ -1,5 +1,9 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; +using Bit.Core; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -136,7 +140,7 @@ public class OrganizationCreateRequestModel : IValidatableObject new string[] { nameof(BillingAddressCountry) }); } - if (PlanType != PlanType.Free && BillingAddressCountry == "US" && + if (PlanType != PlanType.Free && BillingAddressCountry == Constants.CountryAbbreviations.UnitedStates && string.IsNullOrWhiteSpace(BillingAddressPostalCode)) { yield return new ValidationResult("Zip / postal code is required.", diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationDomainSsoDetailsRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationDomainSsoDetailsRequestModel.cs index c5129c6ec7..b4eafc095f 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationDomainSsoDetailsRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationDomainSsoDetailsRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Models.Request.Organizations; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs index d4d69f77c1..7d1efe2315 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs @@ -1,10 +1,8 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; -#nullable enable namespace Bit.Api.AdminConsole.Models.Request.Organizations; @@ -12,8 +10,7 @@ public class OrganizationIntegrationConfigurationRequestModel { public string? Configuration { get; set; } - [Required] - public EventType EventType { get; set; } + public EventType? EventType { get; set; } public string? Filters { get; set; } @@ -33,6 +30,14 @@ public class OrganizationIntegrationConfigurationRequestModel return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid() && IsFiltersValid(); + case IntegrationType.Hec: + return !string.IsNullOrWhiteSpace(Template) && + Configuration is null && + IsFiltersValid(); + case IntegrationType.Datadog: + return !string.IsNullOrWhiteSpace(Template) && + Configuration is null && + IsFiltersValid(); default: return false; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationKeysRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationKeysRequestModel.cs index 4afa5b54ea..22b225a689 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationKeysRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationKeysRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Business; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs index 3255c8b413..0c62b23518 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Bit.Core.Billing.Enums; using Bit.Core.Entities; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs index decc04a0db..5a3192c121 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Data; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs index 2a73f094ed..a5dec192b9 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Enums; using Bit.Core.Models.Business; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs index e6d4f85d3b..4e0accb9e8 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Models.Request; using Bit.Core.Entities; using Bit.Core.Enums; @@ -79,6 +82,10 @@ public class OrganizationUserBulkConfirmRequestModel [Required] public IEnumerable Keys { get; set; } + [EncryptedString] + [EncryptedStringLength(1000)] + public string DefaultUserCollectionName { get; set; } + public Dictionary ToDictionary() { return Keys.ToDictionary(e => e.Id, e => e.Key); diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs index 36dba6ed98..0963887994 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Models.Request.Organizations; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs index 1a5c110254..92d65ab8fe 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs @@ -1,16 +1,16 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; -#nullable enable - namespace Bit.Api.AdminConsole.Models.Request.Organizations; public class OrganizationIntegrationRequestModel : IValidatableObject { - public string? Configuration { get; set; } + public string? Configuration { get; init; } - public IntegrationType Type { get; set; } + public IntegrationType Type { get; init; } public OrganizationIntegration ToOrganizationIntegration(Guid organizationId) { @@ -33,24 +33,55 @@ public class OrganizationIntegrationRequestModel : IValidatableObject switch (Type) { case IntegrationType.CloudBillingSync or IntegrationType.Scim: - yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", new[] { nameof(Type) }); + yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", [nameof(Type)]); break; case IntegrationType.Slack: - yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", new[] { nameof(Type) }); + yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", [nameof(Type)]); break; case IntegrationType.Webhook: - if (Configuration is not null) - { - yield return new ValidationResult( - "Webhook integrations must not include configuration.", - new[] { nameof(Configuration) }); - } + foreach (var r in ValidateConfiguration(allowNullOrEmpty: true)) + yield return r; + break; + case IntegrationType.Hec: + foreach (var r in ValidateConfiguration(allowNullOrEmpty: false)) + yield return r; + break; + case IntegrationType.Datadog: + foreach (var r in ValidateConfiguration(allowNullOrEmpty: false)) + yield return r; break; default: yield return new ValidationResult( $"Integration type '{Type}' is not recognized.", - new[] { nameof(Type) }); + [nameof(Type)]); break; } } + + private List ValidateConfiguration(bool allowNullOrEmpty) + { + var results = new List(); + + if (string.IsNullOrWhiteSpace(Configuration)) + { + if (!allowNullOrEmpty) + results.Add(InvalidConfig()); + return results; + } + + try + { + if (JsonSerializer.Deserialize(Configuration) is null) + results.Add(InvalidConfig()); + } + catch + { + results.Add(InvalidConfig()); + } + + return results; + } + + private static ValidationResult InvalidConfig() => + new(errorMessage: $"Must include valid {typeof(T).Name} configuration.", memberNames: [nameof(Configuration)]); } diff --git a/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs b/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs index a243f46b2e..0e31deacd1 100644 --- a/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationAddRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationAddRequestModel.cs index 207d84b787..9a33431443 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationAddRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationAddRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Models.Request.Providers; diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationCreateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationCreateRequestModel.cs index bf75c611e2..25417d04c5 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationCreateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Core.Utilities; diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs index 697077c9b6..41cebe8b9b 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs @@ -1,7 +1,9 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using Bit.Api.Billing.Models.Requests; -using Bit.Api.Models.Request; +using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Utilities; @@ -25,8 +27,9 @@ public class ProviderSetupRequestModel [Required] public string Key { get; set; } [Required] - public ExpandedTaxInfoUpdateRequestModel TaxInfo { get; set; } - public TokenizedPaymentSourceRequestBody PaymentSource { get; set; } + public MinimalTokenizedPaymentMethodRequest PaymentMethod { get; set; } + [Required] + public BillingAddressRequest BillingAddress { get; set; } public virtual Provider ToProvider(Provider provider) { diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderUpdateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderUpdateRequestModel.cs index e41cb13f4e..8a7ab7643b 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Settings; diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderUserRequestModels.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderUserRequestModels.cs index dd22530916..12b1e0d064 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderUserRequestModels.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderUserRequestModels.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Utilities; diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderVerifyDeleteRecoverRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderVerifyDeleteRecoverRequestModel.cs index edb58c21b1..a3a0f4fba6 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderVerifyDeleteRecoverRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderVerifyDeleteRecoverRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Models.Request.Providers; diff --git a/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs b/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs new file mode 100644 index 0000000000..fcdc49882b --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Context; +using Bit.Core.Utilities; + +namespace Bit.Api.AdminConsole.Models.Request; + +public class SavePolicyRequest +{ + [Required] + public PolicyRequestModel Policy { get; set; } = null!; + + public Dictionary? Metadata { get; set; } + + public async Task ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext) + { + var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)); + + var updatedPolicy = new PolicyUpdate() + { + Type = Policy.Type!.Value, + OrganizationId = organizationId, + Data = Policy.Data != null ? JsonSerializer.Serialize(Policy.Data) : null, + Enabled = Policy.Enabled.GetValueOrDefault(), + }; + + var metadata = MapToPolicyMetadata(); + + return new SavePolicyModel(updatedPolicy, performedBy, metadata); + } + + private IPolicyMetadataModel MapToPolicyMetadata() + { + if (Metadata == null) + { + return new EmptyMetadataModel(); + } + + return Policy?.Type switch + { + PolicyType.OrganizationDataOwnership => MapToPolicyMetadata(), + _ => new EmptyMetadataModel() + }; + } + + private IPolicyMetadataModel MapToPolicyMetadata() where T : IPolicyMetadataModel, new() + { + try + { + var json = JsonSerializer.Serialize(Metadata); + return CoreHelpers.LoadClassFromJsonData(json); + } + catch + { + return new EmptyMetadataModel(); + } + } +} diff --git a/src/Api/AdminConsole/Models/Response/EventResponseModel.cs b/src/Api/AdminConsole/Models/Response/EventResponseModel.cs index 68695b3ab8..c259bc3bc4 100644 --- a/src/Api/AdminConsole/Models/Response/EventResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/EventResponseModel.cs @@ -33,7 +33,9 @@ public class EventResponseModel : ResponseModel SystemUser = ev.SystemUser; DomainName = ev.DomainName; SecretId = ev.SecretId; + ProjectId = ev.ProjectId; ServiceAccountId = ev.ServiceAccountId; + GrantedServiceAccountId = ev.GrantedServiceAccountId; } public EventType Type { get; set; } @@ -55,5 +57,7 @@ public class EventResponseModel : ResponseModel public EventSystemUser? SystemUser { get; set; } public string DomainName { get; set; } public Guid? SecretId { get; set; } + public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } + public Guid? GrantedServiceAccountId { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/GroupResponseModel.cs b/src/Api/AdminConsole/Models/Response/GroupResponseModel.cs index f956f27ebc..741473a5c4 100644 --- a/src/Api/AdminConsole/Models/Response/GroupResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/GroupResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Api; using Bit.Core.Models.Data; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationConnectionResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationConnectionResponseModel.cs index fa6bdc1f3d..f365080b73 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationConnectionResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationConnectionResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs index 590a32ee3d..c7906318e8 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs @@ -25,6 +25,6 @@ public class OrganizationIntegrationConfigurationResponseModel : ResponseModel public string? Configuration { get; set; } public string? Filters { get; set; } public DateTime CreationDate { get; set; } - public EventType EventType { get; set; } + public EventType? EventType { get; set; } public string? Template { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs index cc6e778528..5368f78e39 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs @@ -2,8 +2,6 @@ using Bit.Core.Enums; using Bit.Core.Models.Api; -#nullable enable - namespace Bit.Api.AdminConsole.Models.Response.Organizations; public class OrganizationIntegrationResponseModel : ResponseModel @@ -15,8 +13,35 @@ public class OrganizationIntegrationResponseModel : ResponseModel Id = organizationIntegration.Id; Type = organizationIntegration.Type; + Configuration = organizationIntegration.Configuration; } public Guid Id { get; set; } public IntegrationType Type { get; set; } + public string? Configuration { get; set; } + + public OrganizationIntegrationStatus Status => Type switch + { + // Not yet implemented, shouldn't be present, NotApplicable + IntegrationType.CloudBillingSync => OrganizationIntegrationStatus.NotApplicable, + IntegrationType.Scim => OrganizationIntegrationStatus.NotApplicable, + + // Webhook is allowed to be null. If it's present, it's Completed + IntegrationType.Webhook => OrganizationIntegrationStatus.Completed, + + // If present and the configuration is null, OAuth has been initiated, and we are + // waiting on the return call + IntegrationType.Slack => string.IsNullOrWhiteSpace(Configuration) + ? OrganizationIntegrationStatus.Initiated + : OrganizationIntegrationStatus.Completed, + + // HEC and Datadog should only be allowed to be created non-null. + // If they are null, they are Invalid + IntegrationType.Hec => string.IsNullOrWhiteSpace(Configuration) + ? OrganizationIntegrationStatus.Invalid + : OrganizationIntegrationStatus.Completed, + IntegrationType.Datadog => string.IsNullOrWhiteSpace(Configuration) + ? OrganizationIntegrationStatus.Invalid + : OrganizationIntegrationStatus.Completed, + }; } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationKeysResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationKeysResponseModel.cs index 15dbb18102..1b82d20fb8 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationKeysResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationKeysResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Api; namespace Bit.Api.AdminConsole.Models.Response.Organizations; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationPublicKeyResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationPublicKeyResponseModel.cs index defae9ba4d..b938fd9893 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationPublicKeyResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationPublicKeyResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Api; namespace Bit.Api.AdminConsole.Models.Response.Organizations; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 95754598b9..b34765fb19 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -1,7 +1,11 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Models.Api; using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs index 057841c7d2..eb810599f3 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Api.Models.Response; using Bit.Core.Entities; using Bit.Core.Enums; @@ -233,8 +236,8 @@ public class OrganizationUserPublicKeyResponseModel : ResponseModel public class OrganizationUserBulkResponseModel : ResponseModel { - public OrganizationUserBulkResponseModel(Guid id, string error, - string obj = "OrganizationBulkConfirmResponseModel") : base(obj) + public OrganizationUserBulkResponseModel(Guid id, string error) + : base("OrganizationBulkConfirmResponseModel") { Id = id; Error = error; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs index 86e62a4193..81ca801308 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.Models.Api; @@ -7,6 +10,10 @@ namespace Bit.Api.AdminConsole.Models.Response.Organizations; public class PolicyResponseModel : ResponseModel { + public PolicyResponseModel() : base("policy") + { + } + public PolicyResponseModel(Policy policy, string obj = "policy") : base(obj) { diff --git a/src/Api/AdminConsole/Models/Response/Organizations/VerifiedOrganizationDomainSsoDetailsResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/VerifiedOrganizationDomainSsoDetailsResponseModel.cs index 3488eab2c8..178060d9b1 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/VerifiedOrganizationDomainSsoDetailsResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/VerifiedOrganizationDomainSsoDetailsResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; namespace Bit.Api.AdminConsole.Models.Response.Organizations; diff --git a/src/Api/AdminConsole/Models/Response/PendingOrganizationAuthRequestResponseModel.cs b/src/Api/AdminConsole/Models/Response/PendingOrganizationAuthRequestResponseModel.cs index 3e242cba7b..8952270adf 100644 --- a/src/Api/AdminConsole/Models/Response/PendingOrganizationAuthRequestResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/PendingOrganizationAuthRequestResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Reflection; using Bit.Core.Auth.Models.Data; using Bit.Core.Models.Api; diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index cb0ab62fd1..fd2bfe06dc 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; @@ -75,12 +78,14 @@ public class ProfileOrganizationResponseModel : ResponseModel UseRiskInsights = organization.UseRiskInsights; UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; + SsoEnabled = organization.SsoEnabled ?? false; if (organization.SsoConfig != null) { var ssoConfigData = SsoConfigurationData.Deserialize(organization.SsoConfig); KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl); KeyConnectorUrl = ssoConfigData.KeyConnectorUrl; + SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType; } } @@ -157,4 +162,6 @@ public class ProfileOrganizationResponseModel : ResponseModel public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool IsAdminInitiated { get; set; } + public bool SsoEnabled { get; set; } + public MemberDecryptionType? SsoMemberDecryptionType { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/Providers/ProviderOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Providers/ProviderOrganizationResponseModel.cs index 963fbaa209..c0b492df95 100644 --- a/src/Api/AdminConsole/Models/Response/Providers/ProviderOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Providers/ProviderOrganizationResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.Models.Api; diff --git a/src/Api/AdminConsole/Models/Response/Providers/ProviderResponseModel.cs b/src/Api/AdminConsole/Models/Response/Providers/ProviderResponseModel.cs index 291fb24829..5031c4963d 100644 --- a/src/Api/AdminConsole/Models/Response/Providers/ProviderResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Providers/ProviderResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Models.Api; diff --git a/src/Api/AdminConsole/Public/Controllers/EventsController.cs b/src/Api/AdminConsole/Public/Controllers/EventsController.cs index 992b7453aa..3dd55d51e2 100644 --- a/src/Api/AdminConsole/Public/Controllers/EventsController.cs +++ b/src/Api/AdminConsole/Public/Controllers/EventsController.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.Models.Public.Request; using Bit.Api.Models.Public.Response; using Bit.Core.Context; diff --git a/src/Api/AdminConsole/Public/Controllers/GroupsController.cs b/src/Api/AdminConsole/Public/Controllers/GroupsController.cs index 9ce22536b1..9644d2d799 100644 --- a/src/Api/AdminConsole/Public/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Public/Controllers/GroupsController.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 90c78c9eb7..7bfe5648b6 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -1,8 +1,12 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; @@ -29,6 +33,7 @@ public class MembersController : Controller private readonly IOrganizationRepository _organizationRepository; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; public MembersController( IOrganizationUserRepository organizationUserRepository, @@ -42,7 +47,8 @@ public class MembersController : Controller IPaymentService paymentService, IOrganizationRepository organizationRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IRemoveOrganizationUserCommand removeOrganizationUserCommand) + IRemoveOrganizationUserCommand removeOrganizationUserCommand, + IResendOrganizationInviteCommand resendOrganizationInviteCommand) { _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; @@ -56,6 +62,7 @@ public class MembersController : Controller _organizationRepository = organizationRepository; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; + _resendOrganizationInviteCommand = resendOrganizationInviteCommand; } /// @@ -257,7 +264,7 @@ public class MembersController : Controller { return new NotFoundResult(); } - await _organizationService.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id); + await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id); return new OkResult(); } } diff --git a/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs b/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs index c1715f471c..5531204033 100644 --- a/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs +++ b/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs @@ -1,8 +1,11 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.Models.Public.Response; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Context; -using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Settings; @@ -18,15 +21,21 @@ public class OrganizationController : Controller private readonly IOrganizationService _organizationService; private readonly ICurrentContext _currentContext; private readonly GlobalSettings _globalSettings; + private readonly IImportOrganizationUsersAndGroupsCommand _importOrganizationUsersAndGroupsCommand; + private readonly IFeatureService _featureService; public OrganizationController( IOrganizationService organizationService, ICurrentContext currentContext, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + IImportOrganizationUsersAndGroupsCommand importOrganizationUsersAndGroupsCommand, + IFeatureService featureService) { _organizationService = organizationService; _currentContext = currentContext; _globalSettings = globalSettings; + _importOrganizationUsersAndGroupsCommand = importOrganizationUsersAndGroupsCommand; + _featureService = featureService; } /// @@ -47,13 +56,14 @@ public class OrganizationController : Controller throw new BadRequestException("You cannot import this much data at once."); } - await _organizationService.ImportAsync( - _currentContext.OrganizationId.Value, - model.Groups.Select(g => g.ToImportedGroup(_currentContext.OrganizationId.Value)), - model.Members.Where(u => !u.Deleted).Select(u => u.ToImportedOrganizationUser()), - model.Members.Where(u => u.Deleted).Select(u => u.ExternalId), - model.OverwriteExisting.GetValueOrDefault(), - EventSystemUser.PublicApi); + await _importOrganizationUsersAndGroupsCommand.ImportAsync( + _currentContext.OrganizationId.Value, + model.Groups.Select(g => g.ToImportedGroup(_currentContext.OrganizationId.Value)), + model.Members.Where(u => !u.Deleted).Select(u => u.ToImportedOrganizationUser()), + model.Members.Where(u => u.Deleted).Select(u => u.ExternalId), + model.OverwriteExisting.GetValueOrDefault() + ); + return new OkResult(); } } diff --git a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs index d261a3c555..1caf9cb068 100644 --- a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; diff --git a/src/Api/AdminConsole/Public/Models/GroupBaseModel.cs b/src/Api/AdminConsole/Public/Models/GroupBaseModel.cs index fd42cccffd..d21e9d757f 100644 --- a/src/Api/AdminConsole/Public/Models/GroupBaseModel.cs +++ b/src/Api/AdminConsole/Public/Models/GroupBaseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Public.Models; diff --git a/src/Api/AdminConsole/Public/Models/PolicyBaseModel.cs b/src/Api/AdminConsole/Public/Models/PolicyBaseModel.cs index f474d87ec9..ba455d92e1 100644 --- a/src/Api/AdminConsole/Public/Models/PolicyBaseModel.cs +++ b/src/Api/AdminConsole/Public/Models/PolicyBaseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Public.Models; diff --git a/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs index 202bd5f705..7a76526ede 100644 --- a/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Data; namespace Bit.Api.AdminConsole.Public.Models.Request; diff --git a/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs index 852076eebc..2d96425d55 100644 --- a/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Exceptions; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Exceptions; namespace Bit.Api.Models.Public.Request; diff --git a/src/Api/AdminConsole/Public/Models/Request/GroupCreateUpdateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/GroupCreateUpdateRequestModel.cs index 671503c649..3c531b4208 100644 --- a/src/Api/AdminConsole/Public/Models/Request/GroupCreateUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/GroupCreateUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; namespace Bit.Api.AdminConsole.Public.Models.Request; diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs index f6b2c4d4af..b3182601b5 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; @@ -28,7 +31,7 @@ public class MemberCreateRequestModel : MemberUpdateRequestModel { Emails = new[] { Email }, Type = Type.Value, - Collections = Collections?.Select(c => c.ToCollectionAccessSelection()).ToList(), + Collections = Collections?.Select(c => c.ToCollectionAccessSelection())?.ToList() ?? [], Groups = Groups }; diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs index ac281e3c44..674fa1290f 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Api/AdminConsole/Public/Models/Request/OrganizationImportRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/OrganizationImportRequestModel.cs index 2adda81e49..6122d5dfd0 100644 --- a/src/Api/AdminConsole/Public/Models/Request/OrganizationImportRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/OrganizationImportRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; diff --git a/src/Api/AdminConsole/Public/Models/Request/UpdateGroupIdsRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/UpdateGroupIdsRequestModel.cs index c55be36fff..f3714025ac 100644 --- a/src/Api/AdminConsole/Public/Models/Request/UpdateGroupIdsRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/UpdateGroupIdsRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.AdminConsole.Public.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.AdminConsole.Public.Models.Request; public class UpdateGroupIdsRequestModel { diff --git a/src/Api/AdminConsole/Public/Models/Request/UpdateMemberIdsRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/UpdateMemberIdsRequestModel.cs index 4124719929..bf0ea342ac 100644 --- a/src/Api/AdminConsole/Public/Models/Request/UpdateMemberIdsRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/UpdateMemberIdsRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.AdminConsole.Public.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.AdminConsole.Public.Models.Request; public class UpdateMemberIdsRequestModel { diff --git a/src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs index 0609a4d782..3e1de2747a 100644 --- a/src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/EventResponseModel.cs @@ -28,6 +28,7 @@ public class EventResponseModel : IResponseModel IpAddress = ev.IpAddress; InstallationId = ev.InstallationId; SecretId = ev.SecretId; + ProjectId = ev.ProjectId; ServiceAccountId = ev.ServiceAccountId; } @@ -97,6 +98,11 @@ public class EventResponseModel : IResponseModel /// e68b8629-85eb-4929-92c0-b84464976ba4 public Guid? SecretId { get; set; } /// + /// The unique identifier of the related project that the event describes. + /// + /// e68b8629-85eb-4929-92c0-b84464976ba4 + public Guid? ProjectId { get; set; } + /// /// The unique identifier of the related service account that the event describes. /// /// e68b8629-85eb-4929-92c0-b84464976ba4 diff --git a/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs index c275d1658b..c12616b4cc 100644 --- a/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Data; diff --git a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs index 933cda9dca..70da584621 100644 --- a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using Bit.Api.Models.Public.Response; diff --git a/src/Api/AdminConsole/Public/Models/Response/PolicyResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/PolicyResponseModel.cs index 8da7d93cf1..e43f994255 100644 --- a/src/Api/AdminConsole/Public/Models/Response/PolicyResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/PolicyResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index 11af4d5e0a..138549e92d 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -34,7 +34,7 @@ - + diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index ec542daec7..19165a5a1c 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Response; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Response; @@ -6,12 +9,15 @@ using Bit.Core; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Kdf; using Bit.Core.Models.Api.Response; using Bit.Core.Repositories; using Bit.Core.Services; @@ -22,7 +28,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("accounts")] -[Authorize("Application")] +[Authorize(Policies.Application)] public class AccountsController : Controller { private readonly IOrganizationService _organizationService; @@ -34,6 +40,8 @@ public class AccountsController : Controller private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IFeatureService _featureService; + private readonly ITwoFactorEmailService _twoFactorEmailService; + private readonly IChangeKdfCommand _changeKdfCommand; public AccountsController( IOrganizationService organizationService, @@ -44,7 +52,9 @@ public class AccountsController : Controller ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand, ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IFeatureService featureService + IFeatureService featureService, + ITwoFactorEmailService twoFactorEmailService, + IChangeKdfCommand changeKdfCommand ) { _organizationService = organizationService; @@ -56,6 +66,8 @@ public class AccountsController : Controller _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _featureService = featureService; + _twoFactorEmailService = twoFactorEmailService; + _changeKdfCommand = changeKdfCommand; } @@ -247,7 +259,7 @@ public class AccountsController : Controller } [HttpPost("kdf")] - public async Task PostKdf([FromBody] KdfRequestModel model) + public async Task PostKdf([FromBody] PasswordRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -255,8 +267,12 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - var result = await _userService.ChangeKdfAsync(user, model.MasterPasswordHash, - model.NewMasterPasswordHash, model.Key, model.Kdf.Value, model.KdfIterations.Value, model.KdfMemory, model.KdfParallelism); + if (model.AuthenticationData == null || model.UnlockData == null) + { + throw new BadRequestException("AuthenticationData and UnlockData must be provided."); + } + + var result = await _changeKdfCommand.ChangeKdfAsync(user, model.MasterPasswordHash, model.AuthenticationData.ToData(), model.UnlockData.ToData()); if (result.Succeeded) { return; @@ -335,7 +351,6 @@ public class AccountsController : Controller } [HttpPut("profile")] - [HttpPost("profile")] public async Task PutProfile([FromBody] UpdateProfileRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -354,8 +369,14 @@ public class AccountsController : Controller return response; } + [HttpPost("profile")] + [Obsolete("This endpoint is deprecated. Use PUT /profile instead.")] + public async Task PostProfile([FromBody] UpdateProfileRequestModel model) + { + return await PutProfile(model); + } + [HttpPut("avatar")] - [HttpPost("avatar")] public async Task PutAvatar([FromBody] UpdateAvatarRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -373,6 +394,13 @@ public class AccountsController : Controller return response; } + [HttpPost("avatar")] + [Obsolete("This endpoint is deprecated. Use PUT /avatar instead.")] + public async Task PostAvatar([FromBody] UpdateAvatarRequestModel model) + { + return await PutAvatar(model); + } + [HttpGet("revision-date")] public async Task GetAccountRevisionDate() { @@ -421,7 +449,6 @@ public class AccountsController : Controller } [HttpDelete] - [HttpPost("delete")] public async Task Delete([FromBody] SecretVerificationRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -458,6 +485,13 @@ public class AccountsController : Controller throw new BadRequestException(ModelState); } + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE / instead.")] + public async Task PostDelete([FromBody] SecretVerificationRequestModel model) + { + await Delete(model); + } + [AllowAnonymous] [HttpPost("delete-recover")] public async Task PostDeleteRecover([FromBody] DeleteRecoverRequestModel model) @@ -619,10 +653,16 @@ public class AccountsController : Controller [HttpPost("resend-new-device-otp")] public async Task ResendNewDeviceOtpAsync([FromBody] UnauthenticatedSecretVerificationRequestModel request) { - await _userService.ResendNewDeviceVerificationEmail(request.Email, request.Secret); + var user = await _userService.GetUserByPrincipalAsync(User) ?? throw new UnauthorizedAccessException(); + if (!await _userService.VerifySecretAsync(user, request.Secret)) + { + await Task.Delay(2000); + throw new BadRequestException(string.Empty, "User verification failed."); + } + + await _twoFactorEmailService.SendNewDeviceVerificationEmailAsync(user); } - [HttpPost("verify-devices")] [HttpPut("verify-devices")] public async Task SetUserVerifyDevicesAsync([FromBody] SetVerifyDevicesRequestModel request) { @@ -638,6 +678,13 @@ public class AccountsController : Controller await _userService.SaveUserAsync(user); } + [HttpPost("verify-devices")] + [Obsolete("This endpoint is deprecated. Use PUT /verify-devices instead.")] + public async Task PostSetUserVerifyDevicesAsync([FromBody] SetVerifyDevicesRequestModel request) + { + await SetUserVerifyDevicesAsync(request); + } + private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) { var organizationsClaimingUser = await _userService.GetOrganizationsClaimingUserAsync(userId); diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index f7edc7dec4..e9dfe17c94 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -1,6 +1,10 @@ -using Bit.Api.Auth.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Request.AuthRequest; using Bit.Core.Auth.Services; using Bit.Core.Exceptions; @@ -13,32 +17,24 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("auth-requests")] -[Authorize("Application")] -public class AuthRequestsController : Controller +[Authorize(Policies.Application)] +public class AuthRequestsController( + IUserService userService, + IAuthRequestRepository authRequestRepository, + IGlobalSettings globalSettings, + IAuthRequestService authRequestService) : Controller { - private readonly IUserService _userService; - private readonly IAuthRequestRepository _authRequestRepository; - private readonly IGlobalSettings _globalSettings; - private readonly IAuthRequestService _authRequestService; - - public AuthRequestsController( - IUserService userService, - IAuthRequestRepository authRequestRepository, - IGlobalSettings globalSettings, - IAuthRequestService authRequestService) - { - _userService = userService; - _authRequestRepository = authRequestRepository; - _globalSettings = globalSettings; - _authRequestService = authRequestService; - } + private readonly IUserService _userService = userService; + private readonly IAuthRequestRepository _authRequestRepository = authRequestRepository; + private readonly IGlobalSettings _globalSettings = globalSettings; + private readonly IAuthRequestService _authRequestService = authRequestService; [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var userId = _userService.GetProperUserId(User).Value; var authRequests = await _authRequestRepository.GetManyByUserIdAsync(userId); - var responses = authRequests.Select(a => new AuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault)).ToList(); + var responses = authRequests.Select(a => new AuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault)); return new ListResponseModel(responses); } @@ -56,6 +52,15 @@ public class AuthRequestsController : Controller return new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault); } + [HttpGet("pending")] + public async Task> GetPendingAuthRequestsAsync() + { + var userId = _userService.GetProperUserId(User).Value; + var rawResponse = await _authRequestRepository.GetManyPendingAuthRequestByUserId(userId); + var responses = rawResponse.Select(a => new PendingAuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault)); + return new ListResponseModel(responses); + } + [HttpGet("{id}/response")] [AllowAnonymous] public async Task GetResponse(Guid id, [FromQuery] string code) @@ -95,7 +100,37 @@ public class AuthRequestsController : Controller public async Task Put(Guid id, [FromBody] AuthRequestUpdateRequestModel model) { var userId = _userService.GetProperUserId(User).Value; + + // If the Approving Device is attempting to approve a request, validate the approval + if (model.RequestApproved == true) + { + await ValidateApprovalOfMostRecentAuthRequest(id, userId); + } + var authRequest = await _authRequestService.UpdateAuthRequestAsync(id, userId, model); return new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault); } + + private async Task ValidateApprovalOfMostRecentAuthRequest(Guid id, Guid userId) + { + // Get the current auth request to find the device identifier + var currentAuthRequest = await _authRequestService.GetAuthRequestAsync(id, userId); + if (currentAuthRequest == null) + { + throw new NotFoundException(); + } + + // Get all pending auth requests for this user (returns most recent per device) + var pendingRequests = await _authRequestRepository.GetManyPendingAuthRequestByUserId(userId); + + // Find the most recent request for the same device + var mostRecentForDevice = pendingRequests + .FirstOrDefault(pendingRequest => pendingRequest.RequestDeviceIdentifier == currentAuthRequest.RequestDeviceIdentifier); + + var isMostRecentRequestForDevice = mostRecentForDevice?.Id == id; + if (!isMostRecentRequestForDevice) + { + throw new BadRequestException("This request is no longer valid. Make sure to approve the most recent request."); + } + } } diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 8b40444634..016cd82fe2 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Request.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Response; @@ -15,7 +18,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("emergency-access")] -[Authorize("Application")] +[Authorize(Core.Auth.Identity.Policies.Application)] public class EmergencyAccessController : Controller { private readonly IUserService _userService; @@ -76,7 +79,6 @@ public class EmergencyAccessController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(Guid id, [FromBody] EmergencyAccessUpdateRequestModel model) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); @@ -89,14 +91,27 @@ public class EmergencyAccessController : Controller await _emergencyAccessService.SaveAsync(model.ToEmergencyAccess(emergencyAccess), user); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] + public async Task Post(Guid id, [FromBody] EmergencyAccessUpdateRequestModel model) + { + await Put(id, model); + } + [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(Guid id) { var userId = _userService.GetProperUserId(User); await _emergencyAccessService.DeleteAsync(id, userId.Value); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE /{id} instead.")] + public async Task PostDelete(Guid id) + { + await Delete(id); + } + [HttpPost("invite")] public async Task Invite([FromBody] EmergencyAccessInviteRequestModel model) { @@ -133,7 +148,7 @@ public class EmergencyAccessController : Controller } [HttpPost("{id}/approve")] - public async Task Accept(Guid id) + public async Task Approve(Guid id) { var user = await _userService.GetUserByPrincipalAsync(User); await _emergencyAccessService.ApproveAsync(id, user); diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 83490f1c2f..0af46fb57c 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -1,12 +1,17 @@ -using Bit.Api.Auth.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Response.TwoFactor; using Bit.Api.Models.Request; using Bit.Api.Models.Response; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -22,7 +27,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("two-factor")] -[Authorize("Web")] +[Authorize(Policies.Web)] public class TwoFactorController : Controller { private readonly IUserService _userService; @@ -34,6 +39,7 @@ public class TwoFactorController : Controller private readonly IDuoUniversalTokenService _duoUniversalTokenService; private readonly IDataProtectorTokenFactory _twoFactorAuthenticatorDataProtector; private readonly IDataProtectorTokenFactory _ssoEmailTwoFactorSessionDataProtector; + private readonly ITwoFactorEmailService _twoFactorEmailService; public TwoFactorController( IUserService userService, @@ -44,7 +50,8 @@ public class TwoFactorController : Controller IVerifyAuthRequestCommand verifyAuthRequestCommand, IDuoUniversalTokenService duoUniversalConfigService, IDataProtectorTokenFactory twoFactorAuthenticatorDataProtector, - IDataProtectorTokenFactory ssoEmailTwoFactorSessionDataProtector) + IDataProtectorTokenFactory ssoEmailTwoFactorSessionDataProtector, + ITwoFactorEmailService twoFactorEmailService) { _userService = userService; _organizationRepository = organizationRepository; @@ -55,6 +62,7 @@ public class TwoFactorController : Controller _duoUniversalTokenService = duoUniversalConfigService; _twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector; _ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector; + _twoFactorEmailService = twoFactorEmailService; } [HttpGet("")] @@ -103,7 +111,6 @@ public class TwoFactorController : Controller } [HttpPut("authenticator")] - [HttpPost("authenticator")] public async Task PutAuthenticator( [FromBody] UpdateTwoFactorAuthenticatorRequestModel model) { @@ -126,6 +133,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("authenticator")] + [Obsolete("This endpoint is deprecated. Use PUT /authenticator instead.")] + public async Task PostAuthenticator( + [FromBody] UpdateTwoFactorAuthenticatorRequestModel model) + { + return await PutAuthenticator(model); + } + [HttpDelete("authenticator")] public async Task DisableAuthenticator( [FromBody] TwoFactorAuthenticatorDisableRequestModel model) @@ -150,7 +165,6 @@ public class TwoFactorController : Controller } [HttpPut("yubikey")] - [HttpPost("yubikey")] public async Task PutYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model) { var user = await CheckAsync(model, true); @@ -167,6 +181,13 @@ public class TwoFactorController : Controller return response; } + [HttpPost("yubikey")] + [Obsolete("This endpoint is deprecated. Use PUT /yubikey instead.")] + public async Task PostYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model) + { + return await PutYubiKey(model); + } + [HttpPost("get-duo")] public async Task GetDuo([FromBody] SecretVerificationRequestModel model) { @@ -176,7 +197,6 @@ public class TwoFactorController : Controller } [HttpPut("duo")] - [HttpPost("duo")] public async Task PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model) { var user = await CheckAsync(model, true); @@ -192,6 +212,13 @@ public class TwoFactorController : Controller return response; } + [HttpPost("duo")] + [Obsolete("This endpoint is deprecated. Use PUT /duo instead.")] + public async Task PostDuo([FromBody] UpdateTwoFactorDuoRequestModel model) + { + return await PutDuo(model); + } + [HttpPost("~/organizations/{id}/two-factor/get-duo")] public async Task GetOrganizationDuo(string id, [FromBody] SecretVerificationRequestModel model) @@ -210,7 +237,6 @@ public class TwoFactorController : Controller } [HttpPut("~/organizations/{id}/two-factor/duo")] - [HttpPost("~/organizations/{id}/two-factor/duo")] public async Task PutOrganizationDuo(string id, [FromBody] UpdateTwoFactorDuoRequestModel model) { @@ -236,6 +262,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("~/organizations/{id}/two-factor/duo")] + [Obsolete("This endpoint is deprecated. Use PUT /organizations/{id}/two-factor/duo instead.")] + public async Task PostOrganizationDuo(string id, + [FromBody] UpdateTwoFactorDuoRequestModel model) + { + return await PutOrganizationDuo(id, model); + } + [HttpPost("get-webauthn")] public async Task GetWebAuthn([FromBody] SecretVerificationRequestModel model) { @@ -254,7 +288,6 @@ public class TwoFactorController : Controller } [HttpPut("webauthn")] - [HttpPost("webauthn")] public async Task PutWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model) { var user = await CheckAsync(model, false); @@ -270,6 +303,13 @@ public class TwoFactorController : Controller return response; } + [HttpPost("webauthn")] + [Obsolete("This endpoint is deprecated. Use PUT /webauthn instead.")] + public async Task PostWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model) + { + return await PutWebAuthn(model); + } + [HttpDelete("webauthn")] public async Task DeleteWebAuthn( [FromBody] TwoFactorWebAuthnDeleteRequestModel model) @@ -297,8 +337,9 @@ public class TwoFactorController : Controller public async Task SendEmail([FromBody] TwoFactorEmailRequestModel model) { var user = await CheckAsync(model, false, true); + // Add email to the user's 2FA providers, with the email address they've provided. model.ToUser(user); - await _userService.SendTwoFactorEmailAsync(user, false); + await _twoFactorEmailService.SendTwoFactorSetupEmailAsync(user); } [AllowAnonymous] @@ -316,15 +357,14 @@ public class TwoFactorController : Controller .VerifyAuthRequestAsync(new Guid(requestModel.AuthRequestId), requestModel.AuthRequestAccessCode)) { - await _userService.SendTwoFactorEmailAsync(user); - return; + await _twoFactorEmailService.SendTwoFactorEmailAsync(user); } } else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken)) { if (ValidateSsoEmail2FaToken(requestModel.SsoEmail2FaSessionToken, user)) { - await _userService.SendTwoFactorEmailAsync(user); + await _twoFactorEmailService.SendTwoFactorEmailAsync(user); return; } @@ -333,7 +373,7 @@ public class TwoFactorController : Controller } else if (await _userService.VerifySecretAsync(user, requestModel.Secret)) { - await _userService.SendTwoFactorEmailAsync(user); + await _twoFactorEmailService.SendTwoFactorEmailAsync(user); return; } } @@ -342,7 +382,6 @@ public class TwoFactorController : Controller } [HttpPut("email")] - [HttpPost("email")] public async Task PutEmail([FromBody] UpdateTwoFactorEmailRequestModel model) { var user = await CheckAsync(model, false); @@ -360,8 +399,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("email")] + [Obsolete("This endpoint is deprecated. Use PUT /email instead.")] + public async Task PostEmail([FromBody] UpdateTwoFactorEmailRequestModel model) + { + return await PutEmail(model); + } + [HttpPut("disable")] - [HttpPost("disable")] public async Task PutDisable([FromBody] TwoFactorProviderRequestModel model) { var user = await CheckAsync(model, false); @@ -370,8 +415,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("disable")] + [Obsolete("This endpoint is deprecated. Use PUT /disable instead.")] + public async Task PostDisable([FromBody] TwoFactorProviderRequestModel model) + { + return await PutDisable(model); + } + [HttpPut("~/organizations/{id}/two-factor/disable")] - [HttpPost("~/organizations/{id}/two-factor/disable")] public async Task PutOrganizationDisable(string id, [FromBody] TwoFactorProviderRequestModel model) { @@ -394,6 +445,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("~/organizations/{id}/two-factor/disable")] + [Obsolete("This endpoint is deprecated. Use PUT /organizations/{id}/two-factor/disable instead.")] + public async Task PostOrganizationDisable(string id, + [FromBody] TwoFactorProviderRequestModel model) + { + return await PutOrganizationDisable(id, model); + } + [HttpPost("get-recover")] public async Task GetRecover([FromBody] SecretVerificationRequestModel model) { @@ -402,21 +461,6 @@ public class TwoFactorController : Controller return response; } - /// - /// To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175. - /// - [Obsolete("Two Factor recovery is handled in the TwoFactorAuthenticationValidator.")] - [HttpPost("recover")] - [AllowAnonymous] - public async Task PostRecover([FromBody] TwoFactorRecoveryRequestModel model) - { - if (!await _userService.RecoverTwoFactorAsync(model.Email, model.MasterPasswordHash, model.RecoveryCode)) - { - await Task.Delay(2000); - throw new BadRequestException(string.Empty, "Invalid information. Try again."); - } - } - [Obsolete("Leaving this for backwards compatibility on clients")] [HttpGet("get-device-verification-settings")] public Task GetDeviceVerificationSettings() diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs index bb17607954..60b8621c5e 100644 --- a/src/Api/Auth/Controllers/WebAuthnController.cs +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Response.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; @@ -20,7 +21,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("webauthn")] -[Authorize("Web")] +[Authorize(Policies.Web)] public class WebAuthnController : Controller { private readonly IUserService _userService; diff --git a/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs b/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs index 4ab1f24287..c67cb9db3f 100644 --- a/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs +++ b/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Services; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Services; using Bit.Core.Jobs; using Quartz; diff --git a/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs b/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs index b125e4f057..f23774f060 100644 --- a/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs +++ b/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Services; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Services; using Bit.Core.Jobs; using Quartz; diff --git a/src/Api/Auth/Models/Request/Accounts/DeleteRecoverRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/DeleteRecoverRequestModel.cs index f4326ee6b6..a87836eff9 100644 --- a/src/Api/Auth/Models/Request/Accounts/DeleteRecoverRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/DeleteRecoverRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/EmailRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/EmailRequestModel.cs index 8d45ec41b3..de90b3e83e 100644 --- a/src/Api/Auth/Models/Request/Accounts/EmailRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/EmailRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/EmailTokenRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/EmailTokenRequestModel.cs index bd75b65a5e..ec5f4a27e1 100644 --- a/src/Api/Auth/Models/Request/Accounts/EmailTokenRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/EmailTokenRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs deleted file mode 100644 index fc62f22bab..0000000000 --- a/src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Bit.Core.Enums; -using Bit.Core.Utilities; - -namespace Bit.Api.Auth.Models.Request.Accounts; - -public class KdfRequestModel : PasswordRequestModel, IValidatableObject -{ - [Required] - public KdfType? Kdf { get; set; } - [Required] - public int? KdfIterations { get; set; } - public int? KdfMemory { get; set; } - public int? KdfParallelism { get; set; } - - public override IEnumerable Validate(ValidationContext validationContext) - { - if (Kdf.HasValue && KdfIterations.HasValue) - { - return KdfSettingsValidator.Validate(Kdf.Value, KdfIterations.Value, KdfMemory, KdfParallelism); - } - - return Enumerable.Empty(); - } -} diff --git a/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataAndAuthenticationModel.cs similarity index 90% rename from src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs rename to src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataAndAuthenticationModel.cs index ba57788cec..da361e5a0c 100644 --- a/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataAndAuthenticationModel.cs @@ -7,7 +7,7 @@ using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; -public class MasterPasswordUnlockDataModel : IValidatableObject +public class MasterPasswordUnlockAndAuthenticationDataModel : IValidatableObject { public required KdfType KdfType { get; set; } public required int KdfIterations { get; set; } @@ -45,9 +45,9 @@ public class MasterPasswordUnlockDataModel : IValidatableObject } } - public MasterPasswordUnlockData ToUnlockData() + public MasterPasswordUnlockAndAuthenticationData ToUnlockData() { - var data = new MasterPasswordUnlockData + var data = new MasterPasswordUnlockAndAuthenticationData { KdfType = KdfType, KdfIterations = KdfIterations, diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordHintRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordHintRequestModel.cs index a52b7b5163..1f2bccd1ce 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordHintRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordHintRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index ce197c4aad..8fa51e9f34 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +#nullable enable + +using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Models.Requests; namespace Bit.Api.Auth.Models.Request.Accounts; @@ -6,9 +9,13 @@ public class PasswordRequestModel : SecretVerificationRequestModel { [Required] [StringLength(300)] - public string NewMasterPasswordHash { get; set; } + public required string NewMasterPasswordHash { get; set; } [StringLength(50)] - public string MasterPasswordHint { get; set; } + public string? MasterPasswordHint { get; set; } [Required] - public string Key { get; set; } + public required string Key { get; set; } + + // Note: These will eventually become required, but not all consumers are moved over yet. + public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } + public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } } diff --git a/src/Api/Auth/Models/Request/Accounts/RegenerateTwoFactorRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/RegenerateTwoFactorRequestModel.cs index dbbcdf7331..e59001c203 100644 --- a/src/Api/Auth/Models/Request/Accounts/RegenerateTwoFactorRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/RegenerateTwoFactorRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs index c0191728f4..7e4ce98fa2 100644 --- a/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs index b07c7ea81f..0d809c6c11 100644 --- a/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Api/Auth/Models/Request/Accounts/UnauthenticatedSecretVerificationRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UnauthenticatedSecretVerificationRequestModel.cs index abd37023c8..bf0cbd76ec 100644 --- a/src/Api/Auth/Models/Request/Accounts/UnauthenticatedSecretVerificationRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UnauthenticatedSecretVerificationRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateKeyRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateKeyRequestModel.cs index d3cb5c2442..ad35d98750 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateKeyRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateKeyRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Tools.Models.Request; diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateProfileRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateProfileRequestModel.cs index 76072cb3a4..29ac9c5df9 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateProfileRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateProfileRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs index e246a99c96..e99c990756 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs index d2b8dce727..e071726edf 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Models.Request.Organizations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/VerifyDeleteRecoverRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/VerifyDeleteRecoverRequestModel.cs index 495cd0bdb5..3decedb14d 100644 --- a/src/Api/Auth/Models/Request/Accounts/VerifyDeleteRecoverRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/VerifyDeleteRecoverRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/VerifyEmailRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/VerifyEmailRequestModel.cs index 730e3ee3be..8d086781d9 100644 --- a/src/Api/Auth/Models/Request/Accounts/VerifyEmailRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/VerifyEmailRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/VerifyOTPRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/VerifyOTPRequestModel.cs index 1db68acc99..edfa3ce2b2 100644 --- a/src/Api/Auth/Models/Request/Accounts/VerifyOTPRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/VerifyOTPRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index 8b1d5e883b..33a7e52791 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Utilities; diff --git a/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs b/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs index d82b26aa26..349bdebb88 100644 --- a/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs +++ b/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; @@ -118,7 +121,7 @@ public class SsoConfigurationDataRequest : IValidatableObject new[] { nameof(IdpEntityId) }); } - if (!Uri.IsWellFormedUriString(IdpEntityId, UriKind.Absolute) && string.IsNullOrWhiteSpace(IdpSingleSignOnServiceUrl)) + if (string.IsNullOrWhiteSpace(IdpSingleSignOnServiceUrl)) { yield return new ValidationResult(i18nService.GetLocalizedHtmlString("IdpSingleSignOnServiceUrlValidationError"), new[] { nameof(IdpSingleSignOnServiceUrl) }); @@ -136,6 +139,7 @@ public class SsoConfigurationDataRequest : IValidatableObject new[] { nameof(IdpSingleLogoutServiceUrl) }); } + // TODO: On server, make public certificate required for SAML2 SSO: https://bitwarden.atlassian.net/browse/PM-26028 if (!string.IsNullOrWhiteSpace(IdpX509PublicCert)) { // Validate the certificate is in a valid format diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 8d7df4160d..79df29c928 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; diff --git a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialCreatelRequestModel.cs b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialCreatelRequestModel.cs index 8c6acbc8d4..c73bd94292 100644 --- a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialCreatelRequestModel.cs +++ b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialCreatelRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Fido2NetLib; diff --git a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialUpdateRequestModel.cs b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialUpdateRequestModel.cs index 54244c2dbd..aaae88bd49 100644 --- a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialUpdateRequestModel.cs +++ b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Fido2NetLib; diff --git a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginRotateKeyRequestModel.cs b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginRotateKeyRequestModel.cs index 7e161cfbea..ec4f2b1724 100644 --- a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginRotateKeyRequestModel.cs +++ b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginRotateKeyRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Models.Data; using Bit.Core.Utilities; diff --git a/src/Api/Auth/Models/Response/AuthRequestResponseModel.cs b/src/Api/Auth/Models/Response/AuthRequestResponseModel.cs index 7a9734d844..82aa38c9ac 100644 --- a/src/Api/Auth/Models/Response/AuthRequestResponseModel.cs +++ b/src/Api/Auth/Models/Response/AuthRequestResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Reflection; using Bit.Core.Auth.Entities; using Bit.Core.Enums; diff --git a/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs b/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs index 90b265715d..640c9bb3e0 100644 --- a/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs +++ b/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Vault.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Vault.Models.Response; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; diff --git a/src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs b/src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs index 0d327e1009..a8930bc9eb 100644 --- a/src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs +++ b/src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Models.Api; diff --git a/src/Api/Auth/Models/Response/PendingAuthRequestResponseModel.cs b/src/Api/Auth/Models/Response/PendingAuthRequestResponseModel.cs new file mode 100644 index 0000000000..8428593068 --- /dev/null +++ b/src/Api/Auth/Models/Response/PendingAuthRequestResponseModel.cs @@ -0,0 +1,15 @@ +using Bit.Core.Auth.Models.Data; + +namespace Bit.Api.Auth.Models.Response; + +public class PendingAuthRequestResponseModel : AuthRequestResponseModel +{ + public PendingAuthRequestResponseModel(PendingAuthRequestDetails authRequest, string vaultUri, string obj = "auth-request") + : base(authRequest, vaultUri, obj) + { + ArgumentNullException.ThrowIfNull(authRequest); + RequestDeviceId = authRequest.RequestDeviceId; + } + + public Guid? RequestDeviceId { get; set; } +} diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs index 71569174a7..47cf49c439 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Models.Api; using OtpNet; diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs index 79012783a4..e7e29d06cb 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Entities; diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs index d1d87d85b5..e16f2a6b78 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Models.Api; diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorRecoverResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorRecoverResponseModel.cs index 0022633973..2369c0ea1c 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorRecoverResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorRecoverResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Models.Api; namespace Bit.Api.Auth.Models.Response.TwoFactor; diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs index 2e1d1aa050..cd853e5739 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Entities; using Bit.Core.Models.Api; diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs index 0a97367017..10cc6749e6 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Models.Api; diff --git a/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs b/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs index d521bdac96..517785e6e4 100644 --- a/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs +++ b/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Fido2NetLib; namespace Bit.Api.Auth.Models.Response.WebAuthn; diff --git a/src/Api/Billing/Attributes/InjectOrganizationAttribute.cs b/src/Api/Billing/Attributes/InjectOrganizationAttribute.cs new file mode 100644 index 0000000000..f4c2a8c637 --- /dev/null +++ b/src/Api/Billing/Attributes/InjectOrganizationAttribute.cs @@ -0,0 +1,61 @@ +#nullable enable +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Models.Api; +using Bit.Core.Repositories; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Bit.Api.Billing.Attributes; + +/// +/// An action filter that facilitates the injection of a parameter into the executing action method arguments. +/// +/// +/// This attribute retrieves the organization associated with the 'organizationId' included in the executing context's route data. If the organization cannot be found, +/// the request is terminated with a not found response. +/// The injected +/// parameter must be marked with a [BindNever] attribute to short-circuit the model-binding system. +/// +/// +/// EndpointAsync([BindNever] Organization organization) +/// ]]> +/// +/// +public class InjectOrganizationAttribute : ActionFilterAttribute +{ + public override async Task OnActionExecutionAsync( + ActionExecutingContext context, + ActionExecutionDelegate next) + { + if (!context.RouteData.Values.TryGetValue("organizationId", out var routeValue) || + !Guid.TryParse(routeValue?.ToString(), out var organizationId)) + { + context.Result = new BadRequestObjectResult(new ErrorResponseModel("Route parameter 'organizationId' is missing or invalid.")); + return; + } + + var organizationRepository = context.HttpContext.RequestServices + .GetRequiredService(); + + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + context.Result = new NotFoundObjectResult(new ErrorResponseModel("Organization not found.")); + return; + } + + var organizationParameter = context.ActionDescriptor.Parameters + .FirstOrDefault(p => p.ParameterType == typeof(Organization)); + + if (organizationParameter != null) + { + context.ActionArguments[organizationParameter.Name] = organization; + } + + await next(); + } +} diff --git a/src/Api/Billing/Attributes/InjectProviderAttribute.cs b/src/Api/Billing/Attributes/InjectProviderAttribute.cs new file mode 100644 index 0000000000..e65dda37c3 --- /dev/null +++ b/src/Api/Billing/Attributes/InjectProviderAttribute.cs @@ -0,0 +1,80 @@ +#nullable enable +using Bit.Api.Models.Public.Response; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Bit.Api.Billing.Attributes; + +/// +/// An action filter that facilitates the injection of a parameter into the executing action method arguments after performing an authorization check. +/// +/// +/// This attribute retrieves the provider associated with the 'providerId' included in the executing context's route data. If the provider cannot be found, +/// the request is terminated with a not-found response. It then checks the authorization level for the provider using the provided . +/// If this check fails, the request is terminated with an unauthorized response. +/// The injected +/// parameter must be marked with a [BindNever] attribute to short-circuit the model-binding system. +/// +/// +/// EndpointAsync([BindNever] Provider provider) +/// ]]> +/// +/// The desired access level for the authorization check. +/// +public class InjectProviderAttribute(ProviderUserType providerUserType) : ActionFilterAttribute +{ + public override async Task OnActionExecutionAsync( + ActionExecutingContext context, + ActionExecutionDelegate next) + { + if (!context.RouteData.Values.TryGetValue("providerId", out var routeValue) || + !Guid.TryParse(routeValue?.ToString(), out var providerId)) + { + context.Result = new BadRequestObjectResult(new ErrorResponseModel("Route parameter 'providerId' is missing or invalid.")); + return; + } + + var providerRepository = context.HttpContext.RequestServices + .GetRequiredService(); + + var provider = await providerRepository.GetByIdAsync(providerId); + + if (provider == null) + { + context.Result = new NotFoundObjectResult(new ErrorResponseModel("Provider not found.")); + return; + } + + var currentContext = context.HttpContext.RequestServices.GetRequiredService(); + + var unauthorized = providerUserType switch + { + ProviderUserType.ProviderAdmin => !currentContext.ProviderProviderAdmin(providerId), + ProviderUserType.ServiceUser => !currentContext.ProviderUser(providerId), + _ => false + }; + + if (unauthorized) + { + context.Result = new UnauthorizedObjectResult(new ErrorResponseModel("Unauthorized.")); + return; + } + + var providerParameter = context.ActionDescriptor.Parameters + .FirstOrDefault(p => p.ParameterType == typeof(Provider)); + + if (providerParameter != null) + { + context.ActionArguments[providerParameter.Name] = provider; + } + + await next(); + } +} diff --git a/src/Api/Billing/Attributes/InjectUserAttribute.cs b/src/Api/Billing/Attributes/InjectUserAttribute.cs new file mode 100644 index 0000000000..0b614bdc44 --- /dev/null +++ b/src/Api/Billing/Attributes/InjectUserAttribute.cs @@ -0,0 +1,53 @@ +#nullable enable +using Bit.Core.Entities; +using Bit.Core.Models.Api; +using Bit.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Bit.Api.Billing.Attributes; + +/// +/// An action filter that facilitates the injection of a parameter into the executing action method arguments. +/// +/// +/// This attribute retrieves the authorized user associated with the current HTTP context using the service. +/// If the user is unauthorized or cannot be found, the request is terminated with an unauthorized response. +/// The injected +/// parameter must be marked with a [BindNever] attribute to short-circuit the model-binding system. +/// +/// +/// EndpointAsync([BindNever] User user) +/// ]]> +/// +/// +public class InjectUserAttribute : ActionFilterAttribute +{ + public override async Task OnActionExecutionAsync( + ActionExecutingContext context, + ActionExecutionDelegate next) + { + var userService = context.HttpContext.RequestServices.GetRequiredService(); + + var user = await userService.GetUserByPrincipalAsync(context.HttpContext.User); + + if (user == null) + { + context.Result = new UnauthorizedObjectResult(new ErrorResponseModel("Unauthorized.")); + return; + } + + var userParameter = + context.ActionDescriptor.Parameters.FirstOrDefault(parameter => parameter.ParameterType == typeof(User)); + + if (userParameter != null) + { + context.ActionArguments[userParameter.Name] = user; + } + + await next(); + } +} diff --git a/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs b/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs new file mode 100644 index 0000000000..227b454f9f --- /dev/null +++ b/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs @@ -0,0 +1,13 @@ +using Bit.Api.Utilities; + +namespace Bit.Api.Billing.Attributes; + +public class PaymentMethodTypeValidationAttribute : StringMatchesAttribute +{ + private static readonly string[] _acceptedValues = ["bankAccount", "card", "payPal"]; + + public PaymentMethodTypeValidationAttribute() : base(_acceptedValues) + { + ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}"; + } +} diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 10d386641d..9411d454aa 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -5,6 +5,7 @@ using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Services; using Bit.Core.Exceptions; using Bit.Core.Models.Business; diff --git a/src/Api/Billing/Controllers/BaseBillingController.cs b/src/Api/Billing/Controllers/BaseBillingController.cs index 5f7005fdfc..057c8309fb 100644 --- a/src/Api/Billing/Controllers/BaseBillingController.cs +++ b/src/Api/Billing/Controllers/BaseBillingController.cs @@ -1,4 +1,6 @@ -using Bit.Core.Models.Api; +#nullable enable +using Bit.Core.Billing.Commands; +using Bit.Core.Models.Api; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -6,20 +8,50 @@ namespace Bit.Api.Billing.Controllers; public abstract class BaseBillingController : Controller { + /// + /// Processes the result of a billing command and converts it to an appropriate HTTP result response. + /// + /// + /// Result to response mappings: + /// + /// : 200 OK + /// : 400 BAD_REQUEST + /// : 409 CONFLICT + /// : 500 INTERNAL_SERVER_ERROR + /// + /// + /// The type of the successful result. + /// The result of executing the billing command. + /// An HTTP result response representing the outcome of the command execution. + protected static IResult Handle(BillingCommandResult result) => + result.Match( + TypedResults.Ok, + badRequest => Error.BadRequest(badRequest.Response), + conflict => Error.Conflict(conflict.Response), + unhandled => Error.ServerError(unhandled.Response, unhandled.Exception)); + protected static class Error { - public static BadRequest BadRequest(Dictionary> errors) => - TypedResults.BadRequest(new ErrorResponseModel(errors)); - public static BadRequest BadRequest(string message) => TypedResults.BadRequest(new ErrorResponseModel(message)); + public static JsonHttpResult Conflict(string message) => + TypedResults.Json( + new ErrorResponseModel(message), + statusCode: StatusCodes.Status409Conflict); + public static NotFound NotFound() => TypedResults.NotFound(new ErrorResponseModel("Resource not found.")); - public static JsonHttpResult ServerError(string message = "Something went wrong with your request. Please contact support.") => + public static JsonHttpResult ServerError( + string message = "Something went wrong with your request. Please contact support for assistance.", + Exception? exception = null) => TypedResults.Json( - new ErrorResponseModel(message), + exception == null ? new ErrorResponseModel(message) : new ErrorResponseModel(message) + { + ExceptionMessage = exception.Message, + ExceptionStackTrace = exception.StackTrace + }, statusCode: StatusCodes.Status500InternalServerError); public static JsonHttpResult Unauthorized(string message = "Unauthorized.") => diff --git a/src/Api/Billing/Controllers/BaseProviderController.cs b/src/Api/Billing/Controllers/BaseProviderController.cs index 038abfaa9e..782bffbc70 100644 --- a/src/Api/Billing/Controllers/BaseProviderController.cs +++ b/src/Api/Billing/Controllers/BaseProviderController.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Extensions; using Bit.Core.Context; diff --git a/src/Api/Billing/Controllers/InvoicesController.cs b/src/Api/Billing/Controllers/InvoicesController.cs index 5a1d732f42..30ea975e09 100644 --- a/src/Api/Billing/Controllers/InvoicesController.cs +++ b/src/Api/Billing/Controllers/InvoicesController.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Tax.Requests; using Bit.Core.Context; using Bit.Core.Repositories; diff --git a/src/Api/Controllers/LicensesController.cs b/src/Api/Billing/Controllers/LicensesController.cs similarity index 82% rename from src/Api/Controllers/LicensesController.cs rename to src/Api/Billing/Controllers/LicensesController.cs index 1c00589201..29313bd4d8 100644 --- a/src/Api/Controllers/LicensesController.cs +++ b/src/Api/Billing/Controllers/LicensesController.cs @@ -1,16 +1,20 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Models.Api.OrganizationLicenses; -using Bit.Core.Models.Business; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Controllers; +namespace Bit.Api.Billing.Controllers; [Route("licenses")] [Authorize("Licensing")] @@ -20,7 +24,7 @@ public class LicensesController : Controller private readonly IUserRepository _userRepository; private readonly IUserService _userService; private readonly IOrganizationRepository _organizationRepository; - private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; + private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery; private readonly IValidateBillingSyncKeyCommand _validateBillingSyncKeyCommand; private readonly ICurrentContext _currentContext; @@ -28,14 +32,14 @@ public class LicensesController : Controller IUserRepository userRepository, IUserService userService, IOrganizationRepository organizationRepository, - ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, + IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery, IValidateBillingSyncKeyCommand validateBillingSyncKeyCommand, ICurrentContext currentContext) { _userRepository = userRepository; _userService = userService; _organizationRepository = organizationRepository; - _cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery; + _getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery; _validateBillingSyncKeyCommand = validateBillingSyncKeyCommand; _currentContext = currentContext; } @@ -81,7 +85,7 @@ public class LicensesController : Controller throw new BadRequestException("Invalid Billing Sync Key"); } - var license = await _cloudGetOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value); + var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value); return license; } } diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index f1ab1be6bd..1d6bf51661 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -1,16 +1,8 @@ -#nullable enable -using System.Diagnostics; -using Bit.Api.AdminConsole.Models.Request.Organizations; -using Bit.Api.Billing.Models.Requests; +using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; -using Bit.Api.Billing.Queries.Organizations; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; -using Bit.Core.Billing.Models.Sales; -using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Repositories; using Bit.Core.Services; @@ -27,12 +19,9 @@ public class OrganizationBillingController( ICurrentContext currentContext, IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, - IOrganizationWarningsQuery organizationWarningsQuery, IPaymentService paymentService, - IPricingClient pricingClient, ISubscriberService subscriberService, - IPaymentHistoryService paymentHistoryService, - IUserService userService) : BaseBillingController + IPaymentHistoryService paymentHistoryService) : BaseBillingController { [HttpGet("metadata")] public async Task GetMetadataAsync([FromRoute] Guid organizationId) @@ -265,71 +254,6 @@ public class OrganizationBillingController( return TypedResults.Ok(); } - [HttpPost("restart-subscription")] - public async Task RestartSubscriptionAsync([FromRoute] Guid organizationId, - [FromBody] OrganizationCreateRequestModel model) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - if (organization == null) - { - return Error.NotFound(); - } - var existingPlan = organization.PlanType; - var organizationSignup = model.ToOrganizationSignup(user); - var sale = OrganizationSale.From(organization, organizationSignup); - var plan = await pricingClient.GetPlanOrThrow(model.PlanType); - sale.Organization.PlanType = plan.Type; - sale.Organization.Plan = plan.Name; - sale.SubscriptionSetup.SkipTrial = true; - if (existingPlan == PlanType.Free && organization.GatewaySubscriptionId is not null) - { - sale.Organization.UseTotp = plan.HasTotp; - sale.Organization.UseGroups = plan.HasGroups; - sale.Organization.UseDirectory = plan.HasDirectory; - sale.Organization.SelfHost = plan.HasSelfHost; - sale.Organization.UsersGetPremium = plan.UsersGetPremium; - sale.Organization.UseEvents = plan.HasEvents; - sale.Organization.Use2fa = plan.Has2fa; - sale.Organization.UseApi = plan.HasApi; - sale.Organization.UsePolicies = plan.HasPolicies; - sale.Organization.UseSso = plan.HasSso; - sale.Organization.UseResetPassword = plan.HasResetPassword; - sale.Organization.UseKeyConnector = plan.HasKeyConnector; - sale.Organization.UseScim = plan.HasScim; - sale.Organization.UseCustomPermissions = plan.HasCustomPermissions; - sale.Organization.UseOrganizationDomains = plan.HasOrganizationDomains; - sale.Organization.MaxCollections = plan.PasswordManager.MaxCollections; - } - - if (organizationSignup.PaymentMethodType == null || string.IsNullOrEmpty(organizationSignup.PaymentToken)) - { - return Error.BadRequest("A payment method is required to restart the subscription."); - } - var org = await organizationRepository.GetByIdAsync(organizationId); - Debug.Assert(org is not null, "This organization has already been found via this same ID, this should be fine."); - var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken); - var taxInformation = TaxInformation.From(organizationSignup.TaxInfo); - await organizationBillingService.Finalize(sale); - var updatedOrg = await organizationRepository.GetByIdAsync(organizationId); - if (updatedOrg != null) - { - await organizationBillingService.UpdatePaymentMethod(updatedOrg, paymentSource, taxInformation); - } - - return TypedResults.Ok(); - } - [HttpPost("setup-business-unit")] [SelfHosted(NotSelfHostedOnly = true)] public async Task SetupBusinessUnitAsync( @@ -358,14 +282,13 @@ public class OrganizationBillingController( return TypedResults.Ok(providerId); } - [HttpGet("warnings")] - public async Task GetWarningsAsync([FromRoute] Guid organizationId) + [HttpPost("change-frequency")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task ChangePlanSubscriptionFrequencyAsync( + [FromRoute] Guid organizationId, + [FromBody] ChangePlanFrequencyRequest request) { - /* - * We'll keep these available at the User level, because we're hiding any pertinent information and - * we want to throw as few errors as possible since these are not core features. - */ - if (!await currentContext.OrganizationUser(organizationId)) + if (!await currentContext.EditSubscription(organizationId)) { return Error.Unauthorized(); } @@ -377,8 +300,15 @@ public class OrganizationBillingController( return Error.NotFound(); } - var response = await organizationWarningsQuery.Run(organization); + if (organization.PlanType == request.NewPlanType) + { + return Error.BadRequest("Organization is already on the requested plan frequency."); + } - return TypedResults.Ok(response); + await organizationBillingService.UpdateSubscriptionPlanFrequency( + organization, + request.NewPlanType); + + return TypedResults.Ok(); } } diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index c45b34422c..8c202752de 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Request.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Api.Models.Response.Organizations; using Bit.Core.AdminConsole.Enums; @@ -205,7 +208,6 @@ public class OrganizationSponsorshipsController : Controller [Authorize("Application")] [HttpDelete("{sponsoringOrganizationId}")] - [HttpPost("{sponsoringOrganizationId}/delete")] [SelfHosted(NotSelfHostedOnly = true)] public async Task RevokeSponsorship(Guid sponsoringOrganizationId) { @@ -222,6 +224,15 @@ public class OrganizationSponsorshipsController : Controller await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); } + [Authorize("Application")] + [HttpPost("{sponsoringOrganizationId}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE /{sponsoringOrganizationId} instead.")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostRevokeSponsorship(Guid sponsoringOrganizationId) + { + await RevokeSponsorship(sponsoringOrganizationId); + } + [Authorize("Application")] [HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")] [SelfHosted(NotSelfHostedOnly = true)] @@ -238,7 +249,6 @@ public class OrganizationSponsorshipsController : Controller [Authorize("Application")] [HttpDelete("sponsored/{sponsoredOrgId}")] - [HttpPost("sponsored/{sponsoredOrgId}/remove")] [SelfHosted(NotSelfHostedOnly = true)] public async Task RemoveSponsorship(Guid sponsoredOrgId) { @@ -254,6 +264,15 @@ public class OrganizationSponsorshipsController : Controller await _removeSponsorshipCommand.RemoveSponsorshipAsync(existingOrgSponsorship); } + [Authorize("Application")] + [HttpPost("sponsored/{sponsoredOrgId}/remove")] + [Obsolete("This endpoint is deprecated. Use DELETE /sponsored/{sponsoredOrgId} instead.")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostRemoveSponsorship(Guid sponsoredOrgId) + { + await RemoveSponsorship(sponsoredOrgId); + } + [HttpGet("{sponsoringOrgId}/sync-status")] public async Task GetSyncStatus(Guid sponsoringOrgId) { diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index c8a3c20c91..5494c5a90e 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Request.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Request; @@ -6,16 +9,17 @@ using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Organizations.Entities; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Billing.Organizations.Repositories; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; @@ -35,7 +39,7 @@ public class OrganizationsController( IUserService userService, IPaymentService paymentService, ICurrentContext currentContext, - ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, + IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery, GlobalSettings globalSettings, ILicensingService licensingService, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, @@ -94,7 +98,7 @@ public class OrganizationsController( } var org = await organizationRepository.GetByIdAsync(id); - var license = await cloudGetOrganizationLicenseQuery.GetLicenseAsync(org, installationId); + var license = await getCloudOrganizationLicenseQuery.GetLicenseAsync(org, installationId); if (license == null) { throw new NotFoundException(); @@ -207,18 +211,6 @@ public class OrganizationsController( return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; } - [HttpPost("{id:guid}/verify-bank")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostVerifyBank(Guid id, [FromBody] OrganizationVerifyBankRequestModel model) - { - if (!await currentContext.EditSubscription(id)) - { - throw new NotFoundException(); - } - - await organizationService.VerifyBankAsync(id, model.Amount1.Value, model.Amount2.Value); - } - [HttpPost("{id}/cancel")] public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request) { diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 1309b2df6d..f7d0593812 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -1,7 +1,8 @@ -using Bit.Api.Billing.Models.Requests; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; -using Bit.Commercial.Core.Billing.Providers.Services; -using Bit.Core; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Models; @@ -24,7 +25,6 @@ namespace Bit.Api.Billing.Controllers; [Authorize("Application")] public class ProviderBillingController( ICurrentContext currentContext, - IFeatureService featureService, ILogger logger, IPricingClient pricingClient, IProviderBillingService providerBillingService, @@ -136,27 +136,15 @@ public class ProviderBillingController( var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); - var getProviderPriceFromStripe = featureService.IsEnabled(FeatureFlagKeys.PM21383_GetProviderPriceFromStripe); - var configuredProviderPlans = await Task.WhenAll(providerPlans.Select(async providerPlan => { var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType); + var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, plan.Type); + var price = await stripeAdapter.PriceGetAsync(priceId); - decimal unitAmount; - - if (getProviderPriceFromStripe) - { - var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, plan.Type); - var price = await stripeAdapter.PriceGetAsync(priceId); - - unitAmount = price.UnitAmountDecimal.HasValue - ? price.UnitAmountDecimal.Value / 100M - : plan.PasswordManager.ProviderPortalSeatPrice; - } - else - { - unitAmount = plan.PasswordManager.ProviderPortalSeatPrice; - } + var unitAmount = price.UnitAmountDecimal.HasValue + ? price.UnitAmountDecimal.Value / 100M + : plan.PasswordManager.ProviderPortalSeatPrice; return new ConfiguredProviderPlan( providerPlan.Id, diff --git a/src/Api/Billing/Controllers/TaxController.cs b/src/Api/Billing/Controllers/TaxController.cs index 7b8b9d960f..4ead414589 100644 --- a/src/Api/Billing/Controllers/TaxController.cs +++ b/src/Api/Billing/Controllers/TaxController.cs @@ -1,36 +1,73 @@ -using Bit.Api.Billing.Models.Requests; -using Bit.Core.Billing.Tax.Commands; +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Tax; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Premium.Commands; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Api.Billing.Controllers; [Authorize("Application")] -[Route("tax")] +[Route("billing/tax")] public class TaxController( - IPreviewTaxAmountCommand previewTaxAmountCommand) : BaseBillingController + IPreviewOrganizationTaxCommand previewOrganizationTaxCommand, + IPreviewPremiumTaxCommand previewPremiumTaxCommand) : BaseBillingController { - [HttpPost("preview-amount/organization-trial")] - public async Task PreviewTaxAmountForOrganizationTrialAsync( - [FromBody] PreviewTaxAmountForOrganizationTrialRequestBody requestBody) + [HttpPost("organizations/subscriptions/purchase")] + public async Task PreviewOrganizationSubscriptionPurchaseTaxAsync( + [FromBody] PreviewOrganizationSubscriptionPurchaseTaxRequest request) { - var parameters = new OrganizationTrialParameters + var (purchase, billingAddress) = request.ToDomain(); + var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress); + return Handle(result.Map(pair => new { - PlanType = requestBody.PlanType, - ProductType = requestBody.ProductType, - TaxInformation = new OrganizationTrialParameters.TaxInformationDTO - { - Country = requestBody.TaxInformation.Country, - PostalCode = requestBody.TaxInformation.PostalCode, - TaxId = requestBody.TaxInformation.TaxId - } - }; + pair.Tax, + pair.Total + })); + } - var result = await previewTaxAmountCommand.Run(parameters); + [HttpPost("organizations/{organizationId:guid}/subscription/plan-change")] + [InjectOrganization] + public async Task PreviewOrganizationSubscriptionPlanChangeTaxAsync( + [BindNever] Organization organization, + [FromBody] PreviewOrganizationSubscriptionPlanChangeTaxRequest request) + { + var (planChange, billingAddress) = request.ToDomain(); + var result = await previewOrganizationTaxCommand.Run(organization, planChange, billingAddress); + return Handle(result.Map(pair => new + { + pair.Tax, + pair.Total + })); + } - return result.Match( - taxAmount => TypedResults.Ok(new { TaxAmount = taxAmount }), - badRequest => Error.BadRequest(badRequest.TranslationKey), - unhandled => Error.ServerError(unhandled.TranslationKey)); + [HttpPut("organizations/{organizationId:guid}/subscription/update")] + [InjectOrganization] + public async Task PreviewOrganizationSubscriptionUpdateTaxAsync( + [BindNever] Organization organization, + [FromBody] PreviewOrganizationSubscriptionUpdateTaxRequest request) + { + var update = request.ToDomain(); + var result = await previewOrganizationTaxCommand.Run(organization, update); + return Handle(result.Map(pair => new + { + pair.Tax, + pair.Total + })); + } + + [HttpPost("premium/subscriptions/purchase")] + public async Task PreviewPremiumSubscriptionPurchaseTaxAsync( + [FromBody] PreviewPremiumSubscriptionPurchaseTaxRequest request) + { + var (purchase, billingAddress) = request.ToDomain(); + var result = await previewPremiumTaxCommand.Run(purchase, billingAddress); + return Handle(result.Map(pair => new + { + pair.Tax, + pair.Total + })); } } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs new file mode 100644 index 0000000000..b01b629e4f --- /dev/null +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -0,0 +1,80 @@ +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Api.Billing.Models.Requests.Premium; +using Bit.Core; +using Bit.Core.Billing.Payment.Commands; +using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Entities; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Bit.Api.Billing.Controllers.VNext; + +[Authorize("Application")] +[Route("account/billing/vnext")] +[SelfHosted(NotSelfHostedOnly = true)] +public class AccountBillingVNextController( + ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, + ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand, + IGetCreditQuery getCreditQuery, + IGetPaymentMethodQuery getPaymentMethodQuery, + IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController +{ + [HttpGet("credit")] + [InjectUser] + public async Task GetCreditAsync( + [BindNever] User user) + { + var credit = await getCreditQuery.Run(user); + return TypedResults.Ok(credit); + } + + [HttpPost("credit/bitpay")] + [InjectUser] + public async Task AddCreditViaBitPayAsync( + [BindNever] User user, + [FromBody] BitPayCreditRequest request) + { + var result = await createBitPayInvoiceForCreditCommand.Run( + user, + request.Amount, + request.RedirectUrl); + return Handle(result); + } + + [HttpGet("payment-method")] + [InjectUser] + public async Task GetPaymentMethodAsync( + [BindNever] User user) + { + var paymentMethod = await getPaymentMethodQuery.Run(user); + return TypedResults.Ok(paymentMethod); + } + + [HttpPut("payment-method")] + [InjectUser] + public async Task UpdatePaymentMethodAsync( + [BindNever] User user, + [FromBody] TokenizedPaymentMethodRequest request) + { + var (paymentMethod, billingAddress) = request.ToDomain(); + var result = await updatePaymentMethodCommand.Run(user, paymentMethod, billingAddress); + return Handle(result); + } + + [HttpPost("subscription")] + [RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)] + [InjectUser] + public async Task CreateSubscriptionAsync( + [BindNever] User user, + [FromBody] PremiumCloudHostedSubscriptionRequest request) + { + var (paymentMethod, billingAddress, additionalStorageGb) = request.ToDomain(); + var result = await createPremiumCloudHostedSubscriptionCommand.Run( + user, paymentMethod, billingAddress, additionalStorageGb); + return Handle(result); + } +} diff --git a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs new file mode 100644 index 0000000000..2f825f2cb9 --- /dev/null +++ b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs @@ -0,0 +1,125 @@ +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization.Requirements; +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Api.Billing.Models.Requests.Subscriptions; +using Bit.Api.Billing.Models.Requirements; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Billing.Payment.Commands; +using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +// ReSharper disable RouteTemplates.MethodMissingRouteParameters + +namespace Bit.Api.Billing.Controllers.VNext; + +[Authorize("Application")] +[Route("organizations/{organizationId:guid}/billing/vnext")] +[SelfHosted(NotSelfHostedOnly = true)] +public class OrganizationBillingVNextController( + ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, + IGetBillingAddressQuery getBillingAddressQuery, + IGetCreditQuery getCreditQuery, + IGetOrganizationWarningsQuery getOrganizationWarningsQuery, + IGetPaymentMethodQuery getPaymentMethodQuery, + IRestartSubscriptionCommand restartSubscriptionCommand, + IUpdateBillingAddressCommand updateBillingAddressCommand, + IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController +{ + [Authorize] + [HttpGet("address")] + [InjectOrganization] + public async Task GetBillingAddressAsync( + [BindNever] Organization organization) + { + var billingAddress = await getBillingAddressQuery.Run(organization); + return TypedResults.Ok(billingAddress); + } + + [Authorize] + [HttpPut("address")] + [InjectOrganization] + public async Task UpdateBillingAddressAsync( + [BindNever] Organization organization, + [FromBody] BillingAddressRequest request) + { + var billingAddress = request.ToDomain(); + var result = await updateBillingAddressCommand.Run(organization, billingAddress); + return Handle(result); + } + + [Authorize] + [HttpGet("credit")] + [InjectOrganization] + public async Task GetCreditAsync( + [BindNever] Organization organization) + { + var credit = await getCreditQuery.Run(organization); + return TypedResults.Ok(credit); + } + + [Authorize] + [HttpPost("credit/bitpay")] + [InjectOrganization] + public async Task AddCreditViaBitPayAsync( + [BindNever] Organization organization, + [FromBody] BitPayCreditRequest request) + { + var result = await createBitPayInvoiceForCreditCommand.Run( + organization, + request.Amount, + request.RedirectUrl); + return Handle(result); + } + + [Authorize] + [HttpGet("payment-method")] + [InjectOrganization] + public async Task GetPaymentMethodAsync( + [BindNever] Organization organization) + { + var paymentMethod = await getPaymentMethodQuery.Run(organization); + return TypedResults.Ok(paymentMethod); + } + + [Authorize] + [HttpPut("payment-method")] + [InjectOrganization] + public async Task UpdatePaymentMethodAsync( + [BindNever] Organization organization, + [FromBody] TokenizedPaymentMethodRequest request) + { + var (paymentMethod, billingAddress) = request.ToDomain(); + var result = await updatePaymentMethodCommand.Run(organization, paymentMethod, billingAddress); + return Handle(result); + } + + [Authorize] + [HttpPost("subscription/restart")] + [InjectOrganization] + public async Task RestartSubscriptionAsync( + [BindNever] Organization organization, + [FromBody] RestartSubscriptionRequest request) + { + var (paymentMethod, billingAddress) = request.ToDomain(); + var result = await updatePaymentMethodCommand.Run(organization, paymentMethod, null) + .AndThenAsync(_ => updateBillingAddressCommand.Run(organization, billingAddress)) + .AndThenAsync(_ => restartSubscriptionCommand.Run(organization)); + return Handle(result); + } + + [Authorize] + [HttpGet("warnings")] + [InjectOrganization] + public async Task GetWarningsAsync( + [BindNever] Organization organization) + { + var warnings = await getOrganizationWarningsQuery.Run(organization); + return TypedResults.Ok(warnings); + } +} diff --git a/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs new file mode 100644 index 0000000000..0ea9bad682 --- /dev/null +++ b/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs @@ -0,0 +1,107 @@ +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Payment.Commands; +using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Providers.Queries; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +// ReSharper disable RouteTemplates.MethodMissingRouteParameters + +namespace Bit.Api.Billing.Controllers.VNext; + +[Route("providers/{providerId:guid}/billing/vnext")] +[SelfHosted(NotSelfHostedOnly = true)] +public class ProviderBillingVNextController( + ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, + IGetBillingAddressQuery getBillingAddressQuery, + IGetCreditQuery getCreditQuery, + IGetPaymentMethodQuery getPaymentMethodQuery, + IGetProviderWarningsQuery getProviderWarningsQuery, + IProviderService providerService, + IUpdateBillingAddressCommand updateBillingAddressCommand, + IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController +{ + [HttpGet("address")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task GetBillingAddressAsync( + [BindNever] Provider provider) + { + var billingAddress = await getBillingAddressQuery.Run(provider); + return TypedResults.Ok(billingAddress); + } + + [HttpPut("address")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task UpdateBillingAddressAsync( + [BindNever] Provider provider, + [FromBody] BillingAddressRequest request) + { + var billingAddress = request.ToDomain(); + var result = await updateBillingAddressCommand.Run(provider, billingAddress); + return Handle(result); + } + + [HttpGet("credit")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task GetCreditAsync( + [BindNever] Provider provider) + { + var credit = await getCreditQuery.Run(provider); + return TypedResults.Ok(credit); + } + + [HttpPost("credit/bitpay")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task AddCreditViaBitPayAsync( + [BindNever] Provider provider, + [FromBody] BitPayCreditRequest request) + { + var result = await createBitPayInvoiceForCreditCommand.Run( + provider, + request.Amount, + request.RedirectUrl); + return Handle(result); + } + + [HttpGet("payment-method")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task GetPaymentMethodAsync( + [BindNever] Provider provider) + { + var paymentMethod = await getPaymentMethodQuery.Run(provider); + return TypedResults.Ok(paymentMethod); + } + + [HttpPut("payment-method")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task UpdatePaymentMethodAsync( + [BindNever] Provider provider, + [FromBody] TokenizedPaymentMethodRequest request) + { + var (paymentMethod, billingAddress) = request.ToDomain(); + var result = await updatePaymentMethodCommand.Run(provider, paymentMethod, billingAddress); + // TODO: Temporary until we can send Provider notifications from the Billing API + if (!provider.Enabled) + { + await result.TapAsync(async _ => + { + provider.Enabled = true; + await providerService.UpdateAsync(provider); + }); + } + return Handle(result); + } + + [HttpGet("warnings")] + [InjectProvider(ProviderUserType.ServiceUser)] + public async Task GetWarningsAsync( + [BindNever] Provider provider) + { + var warnings = await getProviderWarningsQuery.Run(provider); + return TypedResults.Ok(warnings); + } +} diff --git a/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs new file mode 100644 index 0000000000..973a7d99a1 --- /dev/null +++ b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs @@ -0,0 +1,38 @@ +#nullable enable +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Premium; +using Bit.Api.Utilities; +using Bit.Core; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Bit.Api.Billing.Controllers.VNext; + +[Authorize("Application")] +[Route("account/billing/vnext/self-host")] +[SelfHosted(SelfHostedOnly = true)] +public class SelfHostedAccountBillingController( + ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController +{ + [HttpPost("license")] + [RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)] + [InjectUser] + public async Task UploadLicenseAsync( + [BindNever] User user, + PremiumSelfHostedSubscriptionRequest request) + { + var license = await ApiHelpers.ReadJsonFileFromBody(HttpContext, request.License); + if (license == null) + { + throw new BadRequestException("Invalid license."); + } + var result = await createPremiumSelfHostedSubscriptionCommand.Run(user, license); + return Handle(result); + } +} diff --git a/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs b/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs index c2add17793..f23ce266c8 100644 --- a/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Requests/ChangePlanFrequencyRequest.cs b/src/Api/Billing/Models/Requests/ChangePlanFrequencyRequest.cs new file mode 100644 index 0000000000..88fff85cb3 --- /dev/null +++ b/src/Api/Billing/Models/Requests/ChangePlanFrequencyRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Enums; + +namespace Bit.Api.Billing.Models.Requests; + +public class ChangePlanFrequencyRequest +{ + [Required] + public PlanType NewPlanType { get; set; } +} diff --git a/src/Api/Billing/Models/Requests/CreateClientOrganizationRequestBody.cs b/src/Api/Billing/Models/Requests/CreateClientOrganizationRequestBody.cs index 95836151d6..243126f7ac 100644 --- a/src/Api/Billing/Models/Requests/CreateClientOrganizationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/CreateClientOrganizationRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Utilities; using Bit.Core.Billing.Enums; diff --git a/src/Api/Billing/Models/Requests/KeyPairRequestBody.cs b/src/Api/Billing/Models/Requests/KeyPairRequestBody.cs index b4f2c00f4f..2fec3bd61d 100644 --- a/src/Api/Billing/Models/Requests/KeyPairRequestBody.cs +++ b/src/Api/Billing/Models/Requests/KeyPairRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs new file mode 100644 index 0000000000..a3856bf173 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Organizations; + +public record OrganizationSubscriptionPlanChangeRequest : IValidatableObject +{ + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public ProductTierType Tier { get; set; } + + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PlanCadenceType Cadence { get; set; } + + public OrganizationSubscriptionPlanChange ToDomain() => new() + { + Tier = Tier, + Cadence = Cadence + }; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Tier == ProductTierType.Families && Cadence == PlanCadenceType.Monthly) + { + yield return new ValidationResult("Monthly billing cadence is not available for the Families plan."); + } + } +} diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs new file mode 100644 index 0000000000..c678b1966c --- /dev/null +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Organizations; + +public record OrganizationSubscriptionPurchaseRequest : IValidatableObject +{ + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public ProductTierType Tier { get; set; } + + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PlanCadenceType Cadence { get; set; } + + [Required] + public required PasswordManagerPurchaseSelections PasswordManager { get; set; } + + public SecretsManagerPurchaseSelections? SecretsManager { get; set; } + + public OrganizationSubscriptionPurchase ToDomain() => new() + { + Tier = Tier, + Cadence = Cadence, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = PasswordManager.Seats, + AdditionalStorage = PasswordManager.AdditionalStorage, + Sponsored = PasswordManager.Sponsored + }, + SecretsManager = SecretsManager != null ? new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = SecretsManager.Seats, + AdditionalServiceAccounts = SecretsManager.AdditionalServiceAccounts, + Standalone = SecretsManager.Standalone + } : null + }; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Tier != ProductTierType.Families) + { + yield break; + } + + if (Cadence == PlanCadenceType.Monthly) + { + yield return new ValidationResult("Monthly cadence is not available on the Families plan."); + } + + if (SecretsManager != null) + { + yield return new ValidationResult("Secrets Manager is not available on the Families plan."); + } + } + + public record PasswordManagerPurchaseSelections + { + [Required] + [Range(1, 100000, ErrorMessage = "Password Manager seats must be between 1 and 100,000")] + public int Seats { get; set; } + + [Required] + [Range(0, 99, ErrorMessage = "Additional storage must be between 0 and 99 GB")] + public int AdditionalStorage { get; set; } + + public bool Sponsored { get; set; } = false; + } + + public record SecretsManagerPurchaseSelections + { + [Required] + [Range(1, 100000, ErrorMessage = "Secrets Manager seats must be between 1 and 100,000")] + public int Seats { get; set; } + + [Required] + [Range(0, 100000, ErrorMessage = "Additional service accounts must be between 0 and 100,000")] + public int AdditionalServiceAccounts { get; set; } + + public bool Standalone { get; set; } = false; + } +} diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs new file mode 100644 index 0000000000..ad5c3bd609 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Organizations; + +public record OrganizationSubscriptionUpdateRequest +{ + public PasswordManagerUpdateSelections? PasswordManager { get; set; } + public SecretsManagerUpdateSelections? SecretsManager { get; set; } + + public OrganizationSubscriptionUpdate ToDomain() => new() + { + PasswordManager = + PasswordManager != null + ? new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = PasswordManager.Seats, + AdditionalStorage = PasswordManager.AdditionalStorage + } + : null, + SecretsManager = + SecretsManager != null + ? new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = SecretsManager.Seats, + AdditionalServiceAccounts = SecretsManager.AdditionalServiceAccounts + } + : null + }; + + public record PasswordManagerUpdateSelections + { + [Range(1, 100000, ErrorMessage = "Password Manager seats must be between 1 and 100,000")] + public int? Seats { get; set; } + + [Range(0, 99, ErrorMessage = "Additional storage must be between 0 and 99 GB")] + public int? AdditionalStorage { get; set; } + } + + public record SecretsManagerUpdateSelections + { + [Range(0, 100000, ErrorMessage = "Secrets Manager seats must be between 0 and 100,000")] + public int? Seats { get; set; } + + [Range(0, 100000, ErrorMessage = "Additional service accounts must be between 0 and 100,000")] + public int? AdditionalServiceAccounts { get; set; } + } +} diff --git a/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs new file mode 100644 index 0000000000..0426a51f10 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs @@ -0,0 +1,19 @@ +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public record BillingAddressRequest : CheckoutBillingAddressRequest +{ + public string? Line1 { get; set; } + public string? Line2 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + + public override BillingAddress ToDomain() => base.ToDomain() with + { + Line1 = Line1, + Line2 = Line2, + City = City, + State = State, + }; +} diff --git a/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs b/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs new file mode 100644 index 0000000000..ec1405c566 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public record BitPayCreditRequest +{ + [Required] + public required decimal Amount { get; set; } + + [Required] + public required string RedirectUrl { get; set; } = null!; +} diff --git a/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs new file mode 100644 index 0000000000..ccf2b30b50 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public record CheckoutBillingAddressRequest : MinimalBillingAddressRequest +{ + public TaxIdRequest? TaxId { get; set; } + + public override BillingAddress ToDomain() => base.ToDomain() with + { + TaxId = TaxId != null ? new TaxID(TaxId.Code, TaxId.Value) : null + }; + + public class TaxIdRequest + { + [Required] + public string Code { get; set; } = null!; + + [Required] + public string Value { get; set; } = null!; + } +} diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs new file mode 100644 index 0000000000..29c10e6631 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public record MinimalBillingAddressRequest +{ + [Required] + [StringLength(2, MinimumLength = 2, ErrorMessage = "Country code must be 2 characters long.")] + public required string Country { get; set; } = null!; + [Required] + public required string PostalCode { get; set; } = null!; + + public virtual BillingAddress ToDomain() => new() { Country = Country, PostalCode = PostalCode, }; +} diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs new file mode 100644 index 0000000000..b0e415c262 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Attributes; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public class MinimalTokenizedPaymentMethodRequest +{ + [Required] + [PaymentMethodTypeValidation] + public required string Type { get; set; } + + [Required] + public required string Token { get; set; } + + public TokenizedPaymentMethod ToDomain() => new() + { + Type = TokenizablePaymentMethodTypeExtensions.From(Type), + Token = Token + }; +} diff --git a/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs new file mode 100644 index 0000000000..2a54313421 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs @@ -0,0 +1,15 @@ +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public class TokenizedPaymentMethodRequest : MinimalTokenizedPaymentMethodRequest +{ + public MinimalBillingAddressRequest? BillingAddress { get; set; } + + public new (TokenizedPaymentMethod, BillingAddress?) ToDomain() + { + var paymentMethod = base.ToDomain(); + var billingAddress = BillingAddress?.ToDomain(); + return (paymentMethod, billingAddress); + } +} diff --git a/src/Api/Billing/Models/Requests/Payment/VerifyBankAccountRequest.cs b/src/Api/Billing/Models/Requests/Payment/VerifyBankAccountRequest.cs new file mode 100644 index 0000000000..2b5d6a0cb1 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/VerifyBankAccountRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public class VerifyBankAccountRequest +{ + [Required] + public required string DescriptorCode { get; set; } +} diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs new file mode 100644 index 0000000000..03f20ec9c1 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Premium; + +public class PremiumCloudHostedSubscriptionRequest +{ + [Required] + public required MinimalTokenizedPaymentMethodRequest TokenizedPaymentMethod { get; set; } + + [Required] + public required MinimalBillingAddressRequest BillingAddress { get; set; } + + [Range(0, 99)] + public short AdditionalStorageGb { get; set; } = 0; + + public (TokenizedPaymentMethod, BillingAddress, short) ToDomain() + { + var paymentMethod = TokenizedPaymentMethod.ToDomain(); + var billingAddress = BillingAddress.ToDomain(); + + return (paymentMethod, billingAddress, AdditionalStorageGb); + } +} diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumSelfHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumSelfHostedSubscriptionRequest.cs new file mode 100644 index 0000000000..261544476e --- /dev/null +++ b/src/Api/Billing/Models/Requests/Premium/PremiumSelfHostedSubscriptionRequest.cs @@ -0,0 +1,10 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests.Premium; + +public class PremiumSelfHostedSubscriptionRequest +{ + [Required] + public required IFormFile License { get; set; } +} diff --git a/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs b/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs deleted file mode 100644 index a3fda0fd6c..0000000000 --- a/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs +++ /dev/null @@ -1,27 +0,0 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; -using Bit.Core.Billing.Enums; - -namespace Bit.Api.Billing.Models.Requests; - -public class PreviewTaxAmountForOrganizationTrialRequestBody -{ - [Required] - public PlanType PlanType { get; set; } - - [Required] - public ProductType ProductType { get; set; } - - [Required] public TaxInformationDTO TaxInformation { get; set; } = null!; - - public class TaxInformationDTO - { - [Required] - public string Country { get; set; } = null!; - - [Required] - public string PostalCode { get; set; } = null!; - - public string? TaxId { get; set; } - } -} diff --git a/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs b/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs index c4b87a01f5..bbc6a9acda 100644 --- a/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs +++ b/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs new file mode 100644 index 0000000000..ac66270427 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Subscriptions; + +public class RestartSubscriptionRequest +{ + [Required] + public required MinimalTokenizedPaymentMethodRequest PaymentMethod { get; set; } + [Required] + public required CheckoutBillingAddressRequest BillingAddress { get; set; } + + public (TokenizedPaymentMethod, BillingAddress) ToDomain() + => (PaymentMethod.ToDomain(), BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs new file mode 100644 index 0000000000..9233a53c85 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Organizations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public record PreviewOrganizationSubscriptionPlanChangeTaxRequest +{ + [Required] + public required OrganizationSubscriptionPlanChangeRequest Plan { get; set; } + + [Required] + public required CheckoutBillingAddressRequest BillingAddress { get; set; } + + public (OrganizationSubscriptionPlanChange, BillingAddress) ToDomain() => + (Plan.ToDomain(), BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs new file mode 100644 index 0000000000..dcc5911f3d --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Organizations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public record PreviewOrganizationSubscriptionPurchaseTaxRequest +{ + [Required] + public required OrganizationSubscriptionPurchaseRequest Purchase { get; set; } + + [Required] + public required CheckoutBillingAddressRequest BillingAddress { get; set; } + + public (OrganizationSubscriptionPurchase, BillingAddress) ToDomain() => + (Purchase.ToDomain(), BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs new file mode 100644 index 0000000000..ae96214ae3 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs @@ -0,0 +1,11 @@ +using Bit.Api.Billing.Models.Requests.Organizations; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public class PreviewOrganizationSubscriptionUpdateTaxRequest +{ + public required OrganizationSubscriptionUpdateRequest Update { get; set; } + + public OrganizationSubscriptionUpdate ToDomain() => Update.ToDomain(); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs new file mode 100644 index 0000000000..76b8a5a444 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public record PreviewPremiumSubscriptionPurchaseTaxRequest +{ + [Required] + [Range(0, 99, ErrorMessage = "Additional storage must be between 0 and 99 GB.")] + public short AdditionalStorage { get; set; } + + [Required] + public required MinimalBillingAddressRequest BillingAddress { get; set; } + + public (short, BillingAddress) ToDomain() => (AdditionalStorage, BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs index edc45ce483..a1b754a9dc 100644 --- a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs b/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs index 4f087913b9..b469ce2576 100644 --- a/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs +++ b/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Utilities; using Bit.Core.Billing.Models; using Bit.Core.Enums; diff --git a/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs b/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs index 6ed1083b42..7c393e342a 100644 --- a/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs b/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs index cdc9a08851..05ab1e34c9 100644 --- a/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs +++ b/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs b/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs index 3e97d07a90..e248d55dde 100644 --- a/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs +++ b/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs b/src/Api/Billing/Models/Requirements/ManageOrganizationBillingRequirement.cs similarity index 58% rename from src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs rename to src/Api/Billing/Models/Requirements/ManageOrganizationBillingRequirement.cs index 84f38e36c2..9978e84f56 100644 --- a/src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs +++ b/src/Api/Billing/Models/Requirements/ManageOrganizationBillingRequirement.cs @@ -1,11 +1,10 @@ -#nullable enable - +using Bit.Api.AdminConsole.Authorization; using Bit.Core.Context; using Bit.Core.Enums; -namespace Bit.Api.AdminConsole.Authorization.Requirements; +namespace Bit.Api.Billing.Models.Requirements; -public class ManageUsersRequirement : IOrganizationRequirement +public class ManageOrganizationBillingRequirement : IOrganizationRequirement { public async Task AuthorizeAsync( CurrentContextOrganization? organizationClaims, @@ -13,8 +12,6 @@ public class ManageUsersRequirement : IOrganizationRequirement => organizationClaims switch { { Type: OrganizationUserType.Owner } => true, - { Type: OrganizationUserType.Admin } => true, - { Permissions.ManageUsers: true } => true, _ => await isProviderUserForOrg() }; } diff --git a/src/Api/Billing/Models/Responses/BillingHistoryResponseModel.cs b/src/Api/Billing/Models/Responses/BillingHistoryResponseModel.cs index 0a4ebdb8dd..9f68fe41a4 100644 --- a/src/Api/Billing/Models/Responses/BillingHistoryResponseModel.cs +++ b/src/Api/Billing/Models/Responses/BillingHistoryResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models; using Bit.Core.Enums; using Bit.Core.Models.Api; diff --git a/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs b/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs index 5c43522aca..f305e41c4f 100644 --- a/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs +++ b/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models; using Bit.Core.Models.Api; namespace Bit.Api.Billing.Models.Responses; diff --git a/src/Api/Billing/Models/Responses/BillingResponseModel.cs b/src/Api/Billing/Models/Responses/BillingResponseModel.cs index 172f784b50..67f4c98f9d 100644 --- a/src/Api/Billing/Models/Responses/BillingResponseModel.cs +++ b/src/Api/Billing/Models/Responses/BillingResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models; using Bit.Core.Enums; using Bit.Core.Models.Api; diff --git a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs index 341dbceadf..a13f267c3b 100644 --- a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs +++ b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Organizations.Models; namespace Bit.Api.Billing.Models.Responses; diff --git a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs index fd248a0a00..a54ac0a876 100644 --- a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs +++ b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs @@ -4,7 +4,7 @@ using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Responses; public record PaymentMethodResponse( - long AccountCredit, + decimal AccountCredit, PaymentSource PaymentSource, string SubscriptionStatus, TaxInformation TaxInformation) diff --git a/src/Api/Billing/Public/Controllers/OrganizationController.cs b/src/Api/Billing/Public/Controllers/OrganizationController.cs index b0a0537ed8..79033ba31e 100644 --- a/src/Api/Billing/Public/Controllers/OrganizationController.cs +++ b/src/Api/Billing/Public/Controllers/OrganizationController.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.Billing.Public.Models; using Bit.Api.Models.Public.Response; using Bit.Core.Billing.Pricing; diff --git a/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs b/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs index 5c75db5924..4ccbdb04e8 100644 --- a/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs +++ b/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; diff --git a/src/Api/Billing/Public/Models/Response/OrganizationSubscriptionDetailsResponseModel.cs b/src/Api/Billing/Public/Models/Response/OrganizationSubscriptionDetailsResponseModel.cs index 09aa7decc1..0a3b7b1421 100644 --- a/src/Api/Billing/Public/Models/Response/OrganizationSubscriptionDetailsResponseModel.cs +++ b/src/Api/Billing/Public/Models/Response/OrganizationSubscriptionDetailsResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Public.Models; diff --git a/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs b/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs deleted file mode 100644 index f6a0e5b1e6..0000000000 --- a/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs +++ /dev/null @@ -1,214 +0,0 @@ -// ReSharper disable InconsistentNaming - -#nullable enable - -using Bit.Api.Billing.Models.Responses.Organizations; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.Enums.Provider; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Services; -using Bit.Core.Context; -using Bit.Core.Services; -using Stripe; -using FreeTrialWarning = Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.FreeTrialWarning; -using InactiveSubscriptionWarning = - Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.InactiveSubscriptionWarning; -using ResellerRenewalWarning = - Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.ResellerRenewalWarning; - -namespace Bit.Api.Billing.Queries.Organizations; - -public interface IOrganizationWarningsQuery -{ - Task Run( - Organization organization); -} - -public class OrganizationWarningsQuery( - ICurrentContext currentContext, - IProviderRepository providerRepository, - IStripeAdapter stripeAdapter, - ISubscriberService subscriberService) : IOrganizationWarningsQuery -{ - public async Task Run( - Organization organization) - { - var response = new OrganizationWarningsResponse(); - - var subscription = - await subscriberService.GetSubscription(organization, - new SubscriptionGetOptions { Expand = ["customer", "latest_invoice", "test_clock"] }); - - if (subscription == null) - { - return response; - } - - response.FreeTrial = await GetFreeTrialWarning(organization, subscription); - - var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); - - response.InactiveSubscription = await GetInactiveSubscriptionWarning(organization, provider, subscription); - - response.ResellerRenewal = await GetResellerRenewalWarning(provider, subscription); - - return response; - } - - private async Task GetFreeTrialWarning( - Organization organization, - Subscription subscription) - { - if (!await currentContext.EditSubscription(organization.Id)) - { - return null; - } - - if (subscription is not - { - Status: StripeConstants.SubscriptionStatus.Trialing, - TrialEnd: not null, - Customer: not null - }) - { - return null; - } - - var customer = subscription.Customer; - - var hasPaymentMethod = - !string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || - !string.IsNullOrEmpty(customer.DefaultSourceId) || - customer.Metadata.ContainsKey(StripeConstants.MetadataKeys.BraintreeCustomerId); - - if (hasPaymentMethod) - { - return null; - } - - var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; - - var remainingTrialDays = (int)Math.Ceiling((subscription.TrialEnd.Value - now).TotalDays); - - return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays }; - } - - private async Task GetInactiveSubscriptionWarning( - Organization organization, - Provider? provider, - Subscription subscription) - { - if (organization.Enabled || - subscription.Status is not StripeConstants.SubscriptionStatus.Unpaid - and not StripeConstants.SubscriptionStatus.Canceled) - { - return null; - } - - if (provider != null) - { - return new InactiveSubscriptionWarning { Resolution = "contact_provider" }; - } - - if (await currentContext.OrganizationOwner(organization.Id)) - { - return subscription.Status switch - { - StripeConstants.SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning - { - Resolution = "add_payment_method" - }, - StripeConstants.SubscriptionStatus.Canceled => new InactiveSubscriptionWarning - { - Resolution = "resubscribe" - }, - _ => null - }; - } - - return new InactiveSubscriptionWarning { Resolution = "contact_owner" }; - } - - private async Task GetResellerRenewalWarning( - Provider? provider, - Subscription subscription) - { - if (provider is not - { - Type: ProviderType.Reseller - }) - { - return null; - } - - if (subscription.CollectionMethod != StripeConstants.CollectionMethod.SendInvoice) - { - return null; - } - - var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; - - // ReSharper disable once ConvertIfStatementToSwitchStatement - if (subscription is - { - Status: StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active, - LatestInvoice: null or { Status: StripeConstants.InvoiceStatus.Paid } - } && (subscription.CurrentPeriodEnd - now).TotalDays <= 14) - { - return new ResellerRenewalWarning - { - Type = "upcoming", - Upcoming = new ResellerRenewalWarning.UpcomingRenewal - { - RenewalDate = subscription.CurrentPeriodEnd - } - }; - } - - if (subscription is - { - Status: StripeConstants.SubscriptionStatus.Active, - LatestInvoice: { Status: StripeConstants.InvoiceStatus.Open, DueDate: not null } - } && subscription.LatestInvoice.DueDate > now) - { - return new ResellerRenewalWarning - { - Type = "issued", - Issued = new ResellerRenewalWarning.IssuedRenewal - { - IssuedDate = subscription.LatestInvoice.Created, - DueDate = subscription.LatestInvoice.DueDate.Value - } - }; - } - - // ReSharper disable once InvertIf - if (subscription.Status == StripeConstants.SubscriptionStatus.PastDue) - { - var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions - { - Query = $"subscription:'{subscription.Id}' status:'open'" - }); - - var earliestOverdueInvoice = openInvoices - .Where(invoice => invoice.DueDate != null && invoice.DueDate < now) - .MinBy(invoice => invoice.Created); - - if (earliestOverdueInvoice != null) - { - return new ResellerRenewalWarning - { - Type = "past_due", - PastDue = new ResellerRenewalWarning.PastDueRenewal - { - SuspensionDate = earliestOverdueInvoice.DueDate!.Value.AddDays(30) - } - }; - } - } - - return null; - } -} diff --git a/src/Api/Billing/Registrations.cs b/src/Api/Billing/Registrations.cs deleted file mode 100644 index cb92098333..0000000000 --- a/src/Api/Billing/Registrations.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Bit.Api.Billing.Queries.Organizations; - -namespace Bit.Api.Billing; - -public static class Registrations -{ - public static void AddBillingQueries(this IServiceCollection services) - { - services.AddTransient(); - } -} diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index c8a12b9c22..b3542cfde2 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Request; using Bit.Api.Models.Response; using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core.Context; @@ -19,7 +22,6 @@ namespace Bit.Api.Controllers; public class CollectionsController : Controller { private readonly ICollectionRepository _collectionRepository; - private readonly ICollectionService _collectionService; private readonly ICreateCollectionCommand _createCollectionCommand; private readonly IUpdateCollectionCommand _updateCollectionCommand; private readonly IDeleteCollectionCommand _deleteCollectionCommand; @@ -30,7 +32,6 @@ public class CollectionsController : Controller public CollectionsController( ICollectionRepository collectionRepository, - ICollectionService collectionService, ICreateCollectionCommand createCollectionCommand, IUpdateCollectionCommand updateCollectionCommand, IDeleteCollectionCommand deleteCollectionCommand, @@ -40,7 +41,6 @@ public class CollectionsController : Controller IBulkAddCollectionAccessCommand bulkAddCollectionAccessCommand) { _collectionRepository = collectionRepository; - _collectionService = collectionService; _createCollectionCommand = createCollectionCommand; _updateCollectionCommand = updateCollectionCommand; _deleteCollectionCommand = deleteCollectionCommand; @@ -102,14 +102,14 @@ public class CollectionsController : Controller } [HttpGet("")] - public async Task> Get(Guid orgId) + public async Task> GetAll(Guid orgId) { IEnumerable orgCollections; var readAllAuthorized = (await _authorizationService.AuthorizeAsync(User, CollectionOperations.ReadAll(orgId))).Succeeded; if (readAllAuthorized) { - orgCollections = await _collectionRepository.GetManyByOrganizationIdAsync(orgId); + orgCollections = await _collectionRepository.GetManySharedCollectionsByOrganizationIdAsync(orgId); } else { @@ -146,7 +146,7 @@ public class CollectionsController : Controller } [HttpPost("")] - public async Task Post(Guid orgId, [FromBody] CollectionRequestModel model) + public async Task Post(Guid orgId, [FromBody] CreateCollectionRequestModel model) { var collection = model.ToCollection(orgId); @@ -173,8 +173,7 @@ public class CollectionsController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] - public async Task Put(Guid orgId, Guid id, [FromBody] CollectionRequestModel model) + public async Task Put(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) { var collection = await _collectionRepository.GetByIdAsync(id); var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.Update)).Succeeded; @@ -198,6 +197,13 @@ public class CollectionsController : Controller return new CollectionAccessDetailsResponseModel(collectionWithPermissions); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] + public async Task PostPut(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) + { + return await Put(orgId, id, model); + } + [HttpPost("bulk-access")] public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model) { @@ -222,7 +228,6 @@ public class CollectionsController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(Guid orgId, Guid id) { var collection = await _collectionRepository.GetByIdAsync(id); @@ -235,8 +240,14 @@ public class CollectionsController : Controller await _deleteCollectionCommand.DeleteAsync(collection); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE /{id} instead.")] + public async Task PostDelete(Guid orgId, Guid id) + { + await Delete(orgId, id); + } + [HttpDelete("")] - [HttpPost("delete")] public async Task DeleteMany(Guid orgId, [FromBody] CollectionBulkDeleteRequestModel model) { var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Ids); @@ -248,4 +259,11 @@ public class CollectionsController : Controller await _deleteCollectionCommand.DeleteManyAsync(collections); } + + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE / instead.")] + public async Task PostDeleteMany(Guid orgId, [FromBody] CollectionBulkDeleteRequestModel model) + { + await DeleteMany(orgId, model); + } } diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index 0ff4e93abe..d54b3c7b8c 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Auth.Models.Request; using Bit.Api.Models.Request; using Bit.Api.Models.Response; @@ -72,7 +75,7 @@ public class DevicesController : Controller } [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var devicesWithPendingAuthData = await _deviceRepository.GetManyByUserIdWithDeviceAuth(_userService.GetProperUserId(User).Value); @@ -96,7 +99,6 @@ public class DevicesController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(string id, [FromBody] DeviceRequestModel model) { var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value); @@ -111,8 +113,14 @@ public class DevicesController : Controller return response; } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] + public async Task PostPut(string id, [FromBody] DeviceRequestModel model) + { + return await Put(id, model); + } + [HttpPut("{identifier}/keys")] - [HttpPost("{identifier}/keys")] public async Task PutKeys(string identifier, [FromBody] DeviceKeysRequestModel model) { var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value); @@ -127,6 +135,13 @@ public class DevicesController : Controller return response; } + [HttpPost("{identifier}/keys")] + [Obsolete("This endpoint is deprecated. Use PUT /{identifier}/keys instead.")] + public async Task PostKeys(string identifier, [FromBody] DeviceKeysRequestModel model) + { + return await PutKeys(identifier, model); + } + [HttpPost("{identifier}/retrieve-keys")] [Obsolete("This endpoint is deprecated. The keys are on the regular device GET endpoints now.")] public async Task GetDeviceKeys(string identifier) @@ -184,7 +199,6 @@ public class DevicesController : Controller } [HttpPut("identifier/{identifier}/token")] - [HttpPost("identifier/{identifier}/token")] public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model) { var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value); @@ -196,8 +210,14 @@ public class DevicesController : Controller await _deviceService.SaveAsync(model.ToDevice(device)); } + [HttpPost("identifier/{identifier}/token")] + [Obsolete("This endpoint is deprecated. Use PUT /identifier/{identifier}/token instead.")] + public async Task PostToken(string identifier, [FromBody] DeviceTokenRequestModel model) + { + await PutToken(identifier, model); + } + [HttpPut("identifier/{identifier}/web-push-auth")] - [HttpPost("identifier/{identifier}/web-push-auth")] public async Task PutWebPushAuth(string identifier, [FromBody] WebPushAuthRequestModel model) { var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value); @@ -206,12 +226,22 @@ public class DevicesController : Controller throw new NotFoundException(); } - await _deviceService.SaveAsync(model.ToData(), device); + await _deviceService.SaveAsync( + model.ToData(), + device, + _currentContext.Organizations.Select(org => org.Id.ToString()) + ); + } + + [HttpPost("identifier/{identifier}/web-push-auth")] + [Obsolete("This endpoint is deprecated. Use PUT /identifier/{identifier}/web-push-auth instead.")] + public async Task PostWebPushAuth(string identifier, [FromBody] WebPushAuthRequestModel model) + { + await PutWebPushAuth(identifier, model); } [AllowAnonymous] [HttpPut("identifier/{identifier}/clear-token")] - [HttpPost("identifier/{identifier}/clear-token")] public async Task PutClearToken(string identifier) { var device = await _deviceRepository.GetByIdentifierAsync(identifier); @@ -223,8 +253,15 @@ public class DevicesController : Controller await _deviceService.ClearTokenAsync(device); } + [AllowAnonymous] + [HttpPost("identifier/{identifier}/clear-token")] + [Obsolete("This endpoint is deprecated. Use PUT /identifier/{identifier}/clear-token instead.")] + public async Task PostClearToken(string identifier) + { + await PutClearToken(identifier); + } + [HttpDelete("{id}")] - [HttpPost("{id}/deactivate")] public async Task Deactivate(string id) { var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value); @@ -236,17 +273,24 @@ public class DevicesController : Controller await _deviceService.DeactivateAsync(device); } + [HttpPost("{id}/deactivate")] + [Obsolete("This endpoint is deprecated. Use DELETE /{id} instead.")] + public async Task PostDeactivate(string id) + { + await Deactivate(id); + } + [AllowAnonymous] [HttpGet("knowndevice")] public async Task GetByIdentifierQuery( [Required][FromHeader(Name = "X-Request-Email")] string Email, [Required][FromHeader(Name = "X-Device-Identifier")] string DeviceIdentifier) - => await GetByIdentifier(CoreHelpers.Base64UrlDecodeString(Email), DeviceIdentifier); + => await GetByEmailAndIdentifier(CoreHelpers.Base64UrlDecodeString(Email), DeviceIdentifier); [Obsolete("Path is deprecated due to encoding issues, use /knowndevice instead.")] [AllowAnonymous] [HttpGet("knowndevice/{email}/{identifier}")] - public async Task GetByIdentifier(string email, string identifier) + public async Task GetByEmailAndIdentifier(string email, string identifier) { if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(identifier)) { diff --git a/src/Api/Controllers/InfoController.cs b/src/Api/Controllers/InfoController.cs index edfd18c79e..590a3006c0 100644 --- a/src/Api/Controllers/InfoController.cs +++ b/src/Api/Controllers/InfoController.cs @@ -6,12 +6,18 @@ namespace Bit.Api.Controllers; public class InfoController : Controller { [HttpGet("~/alive")] - [HttpGet("~/now")] public DateTime GetAlive() { return DateTime.UtcNow; } + [HttpGet("~/now")] + [Obsolete("This endpoint is deprecated. Use GET /alive instead.")] + public DateTime GetNow() + { + return GetAlive(); + } + [HttpGet("~/version")] public JsonResult GetVersion() { diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs index ed501c41da..147f2d52ee 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs @@ -1,13 +1,18 @@ -using Bit.Api.AdminConsole.Models.Response.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Request; using Bit.Api.Models.Request.Organizations; using Bit.Api.Utilities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Models.OrganizationConnectionConfigs; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; @@ -22,33 +27,33 @@ namespace Bit.Api.Controllers.SelfHosted; public class SelfHostedOrganizationLicensesController : Controller { private readonly ICurrentContext _currentContext; - private readonly ISelfHostedGetOrganizationLicenseQuery _selfHostedGetOrganizationLicenseQuery; + private readonly IGetSelfHostedOrganizationLicenseQuery _getSelfHostedOrganizationLicenseQuery; private readonly IOrganizationConnectionRepository _organizationConnectionRepository; - private readonly IOrganizationService _organizationService; + private readonly ISelfHostedOrganizationSignUpCommand _selfHostedOrganizationSignUpCommand; private readonly IOrganizationRepository _organizationRepository; private readonly IUserService _userService; private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand; public SelfHostedOrganizationLicensesController( ICurrentContext currentContext, - ISelfHostedGetOrganizationLicenseQuery selfHostedGetOrganizationLicenseQuery, + IGetSelfHostedOrganizationLicenseQuery getSelfHostedOrganizationLicenseQuery, IOrganizationConnectionRepository organizationConnectionRepository, - IOrganizationService organizationService, + ISelfHostedOrganizationSignUpCommand selfHostedOrganizationSignUpCommand, IOrganizationRepository organizationRepository, IUserService userService, IUpdateOrganizationLicenseCommand updateOrganizationLicenseCommand) { _currentContext = currentContext; - _selfHostedGetOrganizationLicenseQuery = selfHostedGetOrganizationLicenseQuery; + _getSelfHostedOrganizationLicenseQuery = getSelfHostedOrganizationLicenseQuery; _organizationConnectionRepository = organizationConnectionRepository; - _organizationService = organizationService; + _selfHostedOrganizationSignUpCommand = selfHostedOrganizationSignUpCommand; _organizationRepository = organizationRepository; _userService = userService; _updateOrganizationLicenseCommand = updateOrganizationLicenseCommand; } [HttpPost("")] - public async Task PostLicenseAsync(OrganizationCreateLicenseRequestModel model) + public async Task CreateLicenseAsync(OrganizationCreateLicenseRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -62,14 +67,14 @@ public class SelfHostedOrganizationLicensesController : Controller throw new BadRequestException("Invalid license"); } - var result = await _organizationService.SignUpAsync(license, user, model.Key, + var result = await _selfHostedOrganizationSignUpCommand.SignUpAsync(license, user, model.Key, model.CollectionName, model.Keys?.PublicKey, model.Keys?.EncryptedPrivateKey); return new OrganizationResponseModel(result.Item1, null); } [HttpPost("{id}")] - public async Task PostLicenseAsync(string id, LicenseRequestModel model) + public async Task UpdateLicenseAsync(string id, LicenseRequestModel model) { var orgIdGuid = new Guid(id); if (!await _currentContext.OrganizationOwner(orgIdGuid)) @@ -117,7 +122,7 @@ public class SelfHostedOrganizationLicensesController : Controller } var license = - await _selfHostedGetOrganizationLicenseQuery.GetLicenseAsync(selfHostedOrganizationDetails, billingSyncConnection); + await _getSelfHostedOrganizationLicenseQuery.GetLicenseAsync(selfHostedOrganizationDetails, billingSyncConnection); var currentOrganization = await _organizationRepository.GetByLicenseKeyAsync(license.LicenseKey); await _updateOrganizationLicenseCommand.UpdateLicenseAsync(selfHostedOrganizationDetails, license, currentOrganization); diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs index 371b321a4c..198438201c 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Authorization.Requirements; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Core.Context; @@ -76,7 +79,6 @@ public class SelfHostedOrganizationSponsorshipsController : Controller } [HttpDelete("{sponsoringOrgId}")] - [HttpPost("{sponsoringOrgId}/delete")] public async Task RevokeSponsorship(Guid sponsoringOrgId) { var orgUser = await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default); @@ -92,6 +94,13 @@ public class SelfHostedOrganizationSponsorshipsController : Controller await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); } + [HttpPost("{sponsoringOrgId}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE /{sponsoringOrgId} instead.")] + public async Task PostRevokeSponsorship(Guid sponsoringOrgId) + { + await RevokeSponsorship(sponsoringOrgId); + } + [HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")] public async Task AdminInitiatedRevokeSponsorshipAsync(Guid sponsoringOrgId, string sponsoredFriendlyName) { diff --git a/src/Api/Controllers/SettingsController.cs b/src/Api/Controllers/SettingsController.cs index 8489b137e8..e872eeeeac 100644 --- a/src/Api/Controllers/SettingsController.cs +++ b/src/Api/Controllers/SettingsController.cs @@ -32,7 +32,6 @@ public class SettingsController : Controller } [HttpPut("domains")] - [HttpPost("domains")] public async Task PutDomains([FromBody] UpdateDomainsRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -46,4 +45,11 @@ public class SettingsController : Controller var response = new DomainsResponseModel(user); return response; } + + [HttpPost("domains")] + [Obsolete("This endpoint is deprecated. Use PUT /domains instead.")] + public async Task PostDomains([FromBody] UpdateDomainsRequestModel model) + { + return await PutDomains(model); + } } diff --git a/src/Api/Dirt/Controllers/HibpController.cs b/src/Api/Dirt/Controllers/HibpController.cs index e0ec40d0ab..d108fdbd4f 100644 --- a/src/Api/Dirt/Controllers/HibpController.cs +++ b/src/Api/Dirt/Controllers/HibpController.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using System.Security.Cryptography; using Bit.Core.Context; using Bit.Core.Exceptions; diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs new file mode 100644 index 0000000000..bcd64b0bdf --- /dev/null +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -0,0 +1,297 @@ +using Bit.Core.Context; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Dirt.Controllers; + +[Route("reports/organizations")] +[Authorize("Application")] +public class OrganizationReportsController : Controller +{ + private readonly ICurrentContext _currentContext; + private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; + private readonly IAddOrganizationReportCommand _addOrganizationReportCommand; + private readonly IUpdateOrganizationReportCommand _updateOrganizationReportCommand; + private readonly IUpdateOrganizationReportSummaryCommand _updateOrganizationReportSummaryCommand; + private readonly IGetOrganizationReportSummaryDataQuery _getOrganizationReportSummaryDataQuery; + private readonly IGetOrganizationReportSummaryDataByDateRangeQuery _getOrganizationReportSummaryDataByDateRangeQuery; + private readonly IGetOrganizationReportDataQuery _getOrganizationReportDataQuery; + private readonly IUpdateOrganizationReportDataCommand _updateOrganizationReportDataCommand; + private readonly IGetOrganizationReportApplicationDataQuery _getOrganizationReportApplicationDataQuery; + private readonly IUpdateOrganizationReportApplicationDataCommand _updateOrganizationReportApplicationDataCommand; + + public OrganizationReportsController( + ICurrentContext currentContext, + IGetOrganizationReportQuery getOrganizationReportQuery, + IAddOrganizationReportCommand addOrganizationReportCommand, + IUpdateOrganizationReportCommand updateOrganizationReportCommand, + IUpdateOrganizationReportSummaryCommand updateOrganizationReportSummaryCommand, + IGetOrganizationReportSummaryDataQuery getOrganizationReportSummaryDataQuery, + IGetOrganizationReportSummaryDataByDateRangeQuery getOrganizationReportSummaryDataByDateRangeQuery, + IGetOrganizationReportDataQuery getOrganizationReportDataQuery, + IUpdateOrganizationReportDataCommand updateOrganizationReportDataCommand, + IGetOrganizationReportApplicationDataQuery getOrganizationReportApplicationDataQuery, + IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicationDataCommand + ) + { + _currentContext = currentContext; + _getOrganizationReportQuery = getOrganizationReportQuery; + _addOrganizationReportCommand = addOrganizationReportCommand; + _updateOrganizationReportCommand = updateOrganizationReportCommand; + _updateOrganizationReportSummaryCommand = updateOrganizationReportSummaryCommand; + _getOrganizationReportSummaryDataQuery = getOrganizationReportSummaryDataQuery; + _getOrganizationReportSummaryDataByDateRangeQuery = getOrganizationReportSummaryDataByDateRangeQuery; + _getOrganizationReportDataQuery = getOrganizationReportDataQuery; + _updateOrganizationReportDataCommand = updateOrganizationReportDataCommand; + _getOrganizationReportApplicationDataQuery = getOrganizationReportApplicationDataQuery; + _updateOrganizationReportApplicationDataCommand = updateOrganizationReportApplicationDataCommand; + } + + #region Whole OrganizationReport Endpoints + + [HttpGet("{organizationId}/latest")] + public async Task GetLatestOrganizationReportAsync(Guid organizationId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); + + return Ok(latestReport); + } + + [HttpGet("{organizationId}/{reportId}")] + public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + + if (report == null) + { + throw new NotFoundException("Report not found for the specified organization."); + } + + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } + + return Ok(report); + } + + [HttpPost("{organizationId}")] + public async Task CreateOrganizationReportAsync(Guid organizationId, [FromBody] AddOrganizationReportRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + var report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); + return Ok(report); + } + + [HttpPatch("{organizationId}/{reportId}")] + public async Task UpdateOrganizationReportAsync(Guid organizationId, [FromBody] UpdateOrganizationReportRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(request); + return Ok(updatedReport); + } + + #endregion + + # region SummaryData Field Endpoints + + [HttpGet("{organizationId}/data/summary")] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync( + Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (organizationId.Equals(null)) + { + throw new BadRequestException("Organization ID is required."); + } + + var summaryDataList = await _getOrganizationReportSummaryDataByDateRangeQuery + .GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + + return Ok(summaryDataList); + } + + [HttpGet("{organizationId}/data/summary/{reportId}")] + public async Task GetOrganizationReportSummaryAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var summaryData = + await _getOrganizationReportSummaryDataQuery.GetOrganizationReportSummaryDataAsync(organizationId, reportId); + + if (summaryData == null) + { + throw new NotFoundException("Report not found for the specified organization."); + } + + return Ok(summaryData); + } + + [HttpPatch("{organizationId}/data/summary/{reportId}")] + public async Task UpdateOrganizationReportSummaryAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportSummaryRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.ReportId != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); + + return Ok(updatedReport); + } + #endregion + + #region ReportData Field Endpoints + + [HttpGet("{organizationId}/data/report/{reportId}")] + public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var reportData = await _getOrganizationReportDataQuery.GetOrganizationReportDataAsync(organizationId, reportId); + + if (reportData == null) + { + throw new NotFoundException("Organization report data not found."); + } + + return Ok(reportData); + } + + [HttpPatch("{organizationId}/data/report/{reportId}")] + public async Task UpdateOrganizationReportDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportDataRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.ReportId != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); + return Ok(updatedReport); + } + + #endregion + + #region ApplicationData Field Endpoints + + [HttpGet("{organizationId}/data/application/{reportId}")] + public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + { + try + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var applicationData = await _getOrganizationReportApplicationDataQuery.GetOrganizationReportApplicationDataAsync(organizationId, reportId); + + if (applicationData == null) + { + throw new NotFoundException("Organization report application data not found."); + } + + return Ok(applicationData); + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + throw; + } + } + + [HttpPatch("{organizationId}/data/application/{reportId}")] + public async Task UpdateOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportApplicationDataRequest request) + { + try + { + + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.Id != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); + + + + return Ok(updatedReport); + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + throw; + } + } + + #endregion +} diff --git a/src/Api/Dirt/Controllers/ReportsController.cs b/src/Api/Dirt/Controllers/ReportsController.cs index 8bb8b5e487..3e9f2f0e0d 100644 --- a/src/Api/Dirt/Controllers/ReportsController.cs +++ b/src/Api/Dirt/Controllers/ReportsController.cs @@ -1,6 +1,7 @@ using Bit.Api.Dirt.Models; using Bit.Api.Dirt.Models.Response; using Bit.Api.Tools.Models.Response; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Reports.Models.Data; @@ -24,8 +25,8 @@ public class ReportsController : Controller private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery; private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand; private readonly IAddOrganizationReportCommand _addOrganizationReportCommand; - private readonly IDropOrganizationReportCommand _dropOrganizationReportCommand; private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; + private readonly ILogger _logger; public ReportsController( ICurrentContext currentContext, @@ -36,7 +37,7 @@ public class ReportsController : Controller IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand, IGetOrganizationReportQuery getOrganizationReportQuery, IAddOrganizationReportCommand addOrganizationReportCommand, - IDropOrganizationReportCommand dropOrganizationReportCommand + ILogger logger ) { _currentContext = currentContext; @@ -47,7 +48,7 @@ public class ReportsController : Controller _dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand; _getOrganizationReportQuery = getOrganizationReportQuery; _addOrganizationReportCommand = addOrganizationReportCommand; - _dropOrganizationReportCommand = dropOrganizationReportCommand; + _logger = logger; } /// @@ -86,32 +87,24 @@ public class ReportsController : Controller { if (!await _currentContext.AccessReports(orgId)) { + _logger.LogInformation(Constants.BypassFiltersEventId, + "AccessReports Check - UserId: {userId} OrgId: {orgId} DeviceType: {deviceType}", + _currentContext.UserId, orgId, _currentContext.DeviceType); throw new NotFoundException(); } - var accessDetails = await GetMemberAccessDetails(new MemberAccessReportRequest { OrganizationId = orgId }); + _logger.LogInformation(Constants.BypassFiltersEventId, + "MemberAccessReportQuery starts - UserId: {userId} OrgId: {orgId} DeviceType: {deviceType}", + _currentContext.UserId, orgId, _currentContext.DeviceType); + + var accessDetails = await _memberAccessReportQuery + .GetMemberAccessReportsAsync(new MemberAccessReportRequest { OrganizationId = orgId }); var responses = accessDetails.Select(x => new MemberAccessDetailReportResponseModel(x)); return responses; } - /// - /// Contains the organization member info, the cipher ids associated with the member, - /// and details on their collections, groups, and permissions - /// - /// Request parameters - /// - /// List of a user's permissions at a group and collection level as well as the number of ciphers - /// associated with that group/collection - /// - private async Task> GetMemberAccessDetails( - MemberAccessReportRequest request) - { - var accessDetails = await _memberAccessReportQuery.GetMemberAccessReportsAsync(request); - return accessDetails; - } - /// /// Gets the risk insights report details from the risk insights query. Associates a user to their cipher ids /// @@ -213,72 +206,4 @@ public class ReportsController : Controller await _dropPwdHealthReportAppCommand.DropPasswordHealthReportApplicationAsync(request); } - - /// - /// Adds a new organization report - /// - /// A single instance of AddOrganizationReportRequest - /// A single instance of OrganizationReport - /// If user does not have access to the organization - /// If the organization Id is not valid - [HttpPost("organization-reports")] - public async Task AddOrganizationReport([FromBody] AddOrganizationReportRequest request) - { - if (!await _currentContext.AccessReports(request.OrganizationId)) - { - throw new NotFoundException(); - } - return await _addOrganizationReportCommand.AddOrganizationReportAsync(request); - } - - /// - /// Drops organization reports for an organization - /// - /// A single instance of DropOrganizationReportRequest - /// - /// If user does not have access to the organization - /// If the organization does not have any records - [HttpDelete("organization-reports")] - public async Task DropOrganizationReport([FromBody] DropOrganizationReportRequest request) - { - if (!await _currentContext.AccessReports(request.OrganizationId)) - { - throw new NotFoundException(); - } - await _dropOrganizationReportCommand.DropOrganizationReportAsync(request); - } - - /// - /// Gets organization reports for an organization - /// - /// A valid Organization Id - /// An Enumerable of OrganizationReport - /// If user does not have access to the organization - /// If the organization Id is not valid - [HttpGet("organization-reports/{orgId}")] - public async Task> GetOrganizationReports(Guid orgId) - { - if (!await _currentContext.AccessReports(orgId)) - { - throw new NotFoundException(); - } - return await _getOrganizationReportQuery.GetOrganizationReportAsync(orgId); - } - - /// - /// Gets the latest organization report for an organization - /// - /// A valid Organization Id - /// A single instance of OrganizationReport - /// If user does not have access to the organization - /// If the organization Id is not valid - [HttpGet("organization-reports/latest/{orgId}")] - public async Task GetLatestOrganizationReport(Guid orgId) - { - if (!await _currentContext.AccessReports(orgId)) - { - throw new NotFoundException(); - } - return await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(orgId); - } } diff --git a/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs b/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs index 5dbc07afb5..0a57f0117e 100644 --- a/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs +++ b/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.Dirt.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.Dirt.Models; public class PasswordHealthReportApplicationModel { diff --git a/src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs new file mode 100644 index 0000000000..d912fb699e --- /dev/null +++ b/src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs @@ -0,0 +1,9 @@ +namespace Bit.Api.Dirt.Models.Response; + +public class OrganizationReportSummaryModel +{ + public Guid OrganizationId { get; set; } + public required string EncryptedData { get; set; } + public required string EncryptionKey { get; set; } + public DateTime Date { get; set; } +} diff --git a/src/Api/Dockerfile b/src/Api/Dockerfile index 29adde878c..beacee89ae 100644 --- a/src/Api/Dockerfile +++ b/src/Api/Dockerfile @@ -1,7 +1,7 @@ ############################################### # Build stage # ############################################### -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build # Docker buildx supplies the value for this arg ARG TARGETPLATFORM @@ -9,11 +9,11 @@ ARG TARGETPLATFORM # Determine proper runtime value for .NET # We put the value in a file to be read by later layers. RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ - RID=linux-x64 ; \ + RID=linux-musl-x64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ - RID=linux-arm64 ; \ + RID=linux-musl-arm64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ - RID=linux-arm ; \ + RID=linux-musl-arm ; \ fi \ && echo "RID=$RID" > /tmp/rid.txt @@ -37,21 +37,22 @@ RUN . /tmp/rid.txt && dotnet publish \ ############################################### # App stage # ############################################### -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_URLS=http://+:5000 ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - krb5-user \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache curl \ + krb5 \ + icu-libs \ + tzdata \ + shadow \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu # Copy app from the build stage WORKDIR /app diff --git a/src/Api/Jobs/JobsHostedService.cs b/src/Api/Jobs/JobsHostedService.cs index 57b827a8be..0178f6d68b 100644 --- a/src/Api/Jobs/JobsHostedService.cs +++ b/src/Api/Jobs/JobsHostedService.cs @@ -1,4 +1,5 @@ -using Bit.Api.Auth.Jobs; +using Bit.Api.AdminConsole.Jobs; +using Bit.Api.Auth.Jobs; using Bit.Core.Jobs; using Bit.Core.Settings; using Quartz; @@ -65,6 +66,11 @@ public class JobsHostedService : BaseJobsHostedService .WithIntervalInHours(24) .RepeatForever()) .Build(); + var updateOrgSubscriptionsTrigger = TriggerBuilder.Create() + .WithIdentity("UpdateOrgSubscriptionsTrigger") + .StartNow() + .WithCronSchedule("0 0 */3 * * ?") // top of every 3rd hour + .Build(); var jobs = new List> @@ -76,6 +82,7 @@ public class JobsHostedService : BaseJobsHostedService new Tuple(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger), new Tuple(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger), new Tuple(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger), + new (typeof(OrganizationSubscriptionUpdateJob), updateOrgSubscriptionsTrigger), }; if (_globalSettings.SelfHosted && _globalSettings.EnableCloudCommunication) @@ -105,6 +112,7 @@ public class JobsHostedService : BaseJobsHostedService services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } public static void AddCommercialSecretsManagerJobServices(IServiceCollection services) diff --git a/src/Api/Jobs/SelfHostedSponsorshipSyncJob.cs b/src/Api/Jobs/SelfHostedSponsorshipSyncJob.cs index b4d9d75aa0..4e94cced03 100644 --- a/src/Api/Jobs/SelfHostedSponsorshipSyncJob.cs +++ b/src/Api/Jobs/SelfHostedSponsorshipSyncJob.cs @@ -1,9 +1,12 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Services; +using Bit.Core.Enums; using Bit.Core.Jobs; using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Quartz; diff --git a/src/Api/Jobs/ValidateOrganizationsJob.cs b/src/Api/Jobs/ValidateOrganizationsJob.cs index 8c4225a015..b027b4d049 100644 --- a/src/Api/Jobs/ValidateOrganizationsJob.cs +++ b/src/Api/Jobs/ValidateOrganizationsJob.cs @@ -1,5 +1,5 @@ -using Bit.Core.Jobs; -using Bit.Core.Services; +using Bit.Core.Billing.Services; +using Bit.Core.Jobs; using Quartz; namespace Bit.Api.Jobs; diff --git a/src/Api/Jobs/ValidateUsersJob.cs b/src/Api/Jobs/ValidateUsersJob.cs index be531b47de..351e141113 100644 --- a/src/Api/Jobs/ValidateUsersJob.cs +++ b/src/Api/Jobs/ValidateUsersJob.cs @@ -1,5 +1,5 @@ -using Bit.Core.Jobs; -using Bit.Core.Services; +using Bit.Core.Billing.Services; +using Bit.Core.Jobs; using Quartz; namespace Bit.Api.Jobs; diff --git a/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs b/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs new file mode 100644 index 0000000000..904304a633 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class KdfRequestModel +{ + [Required] + public required KdfType KdfType { get; init; } + [Required] + public required int Iterations { get; init; } + public int? Memory { get; init; } + public int? Parallelism { get; init; } + + public KdfSettings ToData() + { + return new KdfSettings + { + KdfType = KdfType, + Iterations = Iterations, + Memory = Memory, + Parallelism = Parallelism + }; + } +} diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs new file mode 100644 index 0000000000..d65dc8fcb7 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class MasterPasswordAuthenticationDataRequestModel +{ + public required KdfRequestModel Kdf { get; init; } + public required string MasterPasswordAuthenticationHash { get; init; } + [StringLength(256)] public required string Salt { get; init; } + + public MasterPasswordAuthenticationData ToData() + { + return new MasterPasswordAuthenticationData + { + Kdf = Kdf.ToData(), + MasterPasswordAuthenticationHash = MasterPasswordAuthenticationHash, + Salt = Salt + }; + } +} diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs new file mode 100644 index 0000000000..ce7a2b343f --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class MasterPasswordUnlockDataRequestModel +{ + public required KdfRequestModel Kdf { get; init; } + [EncryptedString] public required string MasterKeyWrappedUserKey { get; init; } + [StringLength(256)] public required string Salt { get; init; } + + public MasterPasswordUnlockData ToData() + { + return new MasterPasswordUnlockData + { + Kdf = Kdf.ToData(), + MasterKeyWrappedUserKey = MasterKeyWrappedUserKey, + Salt = Salt + }; + } +} diff --git a/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs index bac42bc302..9f52a97383 100644 --- a/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs index 23c3eb95d0..3af944110c 100644 --- a/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs @@ -10,7 +10,7 @@ namespace Bit.Api.KeyManagement.Models.Requests; public class UnlockDataRequestModel { // All methods to get to the userkey - public required MasterPasswordUnlockDataModel MasterPasswordUnlockData { get; set; } + public required MasterPasswordUnlockAndAuthenticationDataModel MasterPasswordUnlockData { get; set; } public required IEnumerable EmergencyAccessUnlockData { get; set; } public required IEnumerable OrganizationAccountRecoveryUnlockData { get; set; } public required IEnumerable PasskeyUnlockData { get; set; } diff --git a/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs b/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs index 9c7efe0fbe..e92be11cd2 100644 --- a/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs +++ b/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs @@ -1,4 +1,5 @@ using Bit.Api.Auth.Models.Request.WebAuthn; +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; @@ -6,7 +7,13 @@ using Bit.Core.Exceptions; namespace Bit.Api.KeyManagement.Validators; -public class WebAuthnLoginKeyRotationValidator : IRotationValidator, IEnumerable> +/// +/// Validates WebAuthn credentials during key rotation. Only processes credentials that have PRF enabled +/// and have encrypted user, public, and private keys. Ensures all such credentials are included +/// in the rotation request with the required encrypted keys. +/// +public class WebAuthnLoginKeyRotationValidator : IRotationValidator, + IEnumerable> { private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; @@ -15,24 +22,20 @@ public class WebAuthnLoginKeyRotationValidator : IRotationValidator> ValidateAsync(User user, IEnumerable keysToRotate) + public async Task> ValidateAsync(User user, + IEnumerable keysToRotate) { var result = new List(); - var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id); - if (existing == null) + var validCredentials = (await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id)) + .Where(credential => credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled).ToList(); + if (validCredentials.Count == 0) { return result; } - var validCredentials = existing.Where(credential => credential.SupportsPrf); - if (!validCredentials.Any()) + foreach (var webAuthnCredential in validCredentials) { - return result; - } - - foreach (var ea in validCredentials) - { - var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == ea.Id); + var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == webAuthnCredential.Id); if (keyToRotate == null) { throw new BadRequestException("All existing webauthn prf keys must be included in the rotation."); @@ -42,6 +45,7 @@ public class WebAuthnLoginKeyRotationValidator : IRotationValidator new AssociationWithPermissionsResponseModel(c)); - Type = collection.Type; } /// @@ -40,8 +41,4 @@ public class CollectionResponseModel : CollectionBaseModel, IResponseModel /// The associated groups that this collection is assigned to. /// public IEnumerable Groups { get; set; } - /// - /// The type of this collection - /// - public CollectionType Type { get; set; } } diff --git a/src/Api/Models/Public/Response/ErrorResponseModel.cs b/src/Api/Models/Public/Response/ErrorResponseModel.cs index 4a4887a0e7..c5bb06d02e 100644 --- a/src/Api/Models/Public/Response/ErrorResponseModel.cs +++ b/src/Api/Models/Public/Response/ErrorResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Api.Models.Public.Response; diff --git a/src/Api/Models/Request/Accounts/PremiumRequestModel.cs b/src/Api/Models/Request/Accounts/PremiumRequestModel.cs index 26d199381f..8e9aac8cc2 100644 --- a/src/Api/Models/Request/Accounts/PremiumRequestModel.cs +++ b/src/Api/Models/Request/Accounts/PremiumRequestModel.cs @@ -1,4 +1,8 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; +using Bit.Core; using Bit.Core.Settings; using Enums = Bit.Core.Enums; @@ -32,7 +36,7 @@ public class PremiumRequestModel : IValidatableObject { yield return new ValidationResult("Payment token or license is required."); } - if (Country == "US" && string.IsNullOrWhiteSpace(PostalCode)) + if (Country == Constants.CountryAbbreviations.UnitedStates && string.IsNullOrWhiteSpace(PostalCode)) { yield return new ValidationResult("Zip / postal code is required.", new string[] { nameof(PostalCode) }); diff --git a/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs b/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs index f51580408a..d3e3f5ec55 100644 --- a/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs +++ b/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs @@ -1,4 +1,8 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; +using Bit.Core; namespace Bit.Api.Models.Request.Accounts; @@ -10,7 +14,7 @@ public class TaxInfoUpdateRequestModel : IValidatableObject public virtual IEnumerable Validate(ValidationContext validationContext) { - if (Country == "US" && string.IsNullOrWhiteSpace(PostalCode)) + if (Country == Constants.CountryAbbreviations.UnitedStates && string.IsNullOrWhiteSpace(PostalCode)) { yield return new ValidationResult("Zip / postal code is required.", new string[] { nameof(PostalCode) }); diff --git a/src/Api/Models/Request/Accounts/UpdateAvatarRequestModel.cs b/src/Api/Models/Request/Accounts/UpdateAvatarRequestModel.cs index 2dd7b27945..225bccc4bf 100644 --- a/src/Api/Models/Request/Accounts/UpdateAvatarRequestModel.cs +++ b/src/Api/Models/Request/Accounts/UpdateAvatarRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; namespace Bit.Api.Models.Request.Accounts; diff --git a/src/Api/Models/Request/BitPayInvoiceRequestModel.cs b/src/Api/Models/Request/BitPayInvoiceRequestModel.cs index 66a5931ca0..d27736d712 100644 --- a/src/Api/Models/Request/BitPayInvoiceRequestModel.cs +++ b/src/Api/Models/Request/BitPayInvoiceRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Settings; namespace Bit.Api.Models.Request; diff --git a/src/Api/Models/Request/BulkCollectionAccessRequestModel.cs b/src/Api/Models/Request/BulkCollectionAccessRequestModel.cs index 8076d8ea5a..f0874cf987 100644 --- a/src/Api/Models/Request/BulkCollectionAccessRequestModel.cs +++ b/src/Api/Models/Request/BulkCollectionAccessRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.Models.Request; public class BulkCollectionAccessRequestModel { diff --git a/src/Api/Models/Request/CollectionRequestModel.cs b/src/Api/Models/Request/CollectionRequestModel.cs index 59fa0160a3..6e73c37db6 100644 --- a/src/Api/Models/Request/CollectionRequestModel.cs +++ b/src/Api/Models/Request/CollectionRequestModel.cs @@ -1,10 +1,13 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Utilities; namespace Bit.Api.Models.Request; -public class CollectionRequestModel +public class CreateCollectionRequestModel { [Required] [EncryptedString] @@ -37,7 +40,7 @@ public class CollectionBulkDeleteRequestModel public IEnumerable Ids { get; set; } } -public class CollectionWithIdRequestModel : CollectionRequestModel +public class CollectionWithIdRequestModel : CreateCollectionRequestModel { public Guid? Id { get; set; } @@ -47,3 +50,21 @@ public class CollectionWithIdRequestModel : CollectionRequestModel return base.ToCollection(existingCollection); } } + +public class UpdateCollectionRequestModel : CreateCollectionRequestModel +{ + [EncryptedString] + [EncryptedStringLength(1000)] + public new string Name { get; set; } + + public override Collection ToCollection(Collection existingCollection) + { + if (string.IsNullOrEmpty(existingCollection.DefaultUserCollectionEmail) && !string.IsNullOrWhiteSpace(Name)) + { + existingCollection.Name = Name; + } + existingCollection.ExternalId = ExternalId; + return existingCollection; + } + +} diff --git a/src/Api/Models/Request/DeviceRequestModels.cs b/src/Api/Models/Request/DeviceRequestModels.cs index 99465501d9..11600a0195 100644 --- a/src/Api/Models/Request/DeviceRequestModels.cs +++ b/src/Api/Models/Request/DeviceRequestModels.cs @@ -1,7 +1,10 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.NotificationHub; +using Bit.Core.Platform.PushRegistration; using Bit.Core.Utilities; namespace Bit.Api.Models.Request; diff --git a/src/Api/Models/Request/ExpandedTaxInfoUpdateRequestModel.cs b/src/Api/Models/Request/ExpandedTaxInfoUpdateRequestModel.cs index 7f95d755a5..5b526360f9 100644 --- a/src/Api/Models/Request/ExpandedTaxInfoUpdateRequestModel.cs +++ b/src/Api/Models/Request/ExpandedTaxInfoUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Request.Accounts; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Request.Accounts; namespace Bit.Api.Models.Request; diff --git a/src/Api/Models/Request/LicenseRequestModel.cs b/src/Api/Models/Request/LicenseRequestModel.cs index 7b66d95f0e..8851f71eaa 100644 --- a/src/Api/Models/Request/LicenseRequestModel.cs +++ b/src/Api/Models/Request/LicenseRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Models.Request; diff --git a/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs index 829840c896..93866161c0 100644 --- a/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs @@ -1,5 +1,4 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Services; +using Bit.Core.AdminConsole.Models.Business; namespace Bit.Api.Models.Request.Organizations; @@ -10,12 +9,11 @@ public class OrganizationCollectionManagementUpdateRequestModel public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } - public virtual Organization ToOrganization(Organization existingOrganization, IFeatureService featureService) + public OrganizationCollectionManagementSettings ToSettings() => new() { - existingOrganization.LimitCollectionCreation = LimitCollectionCreation; - existingOrganization.LimitCollectionDeletion = LimitCollectionDeletion; - existingOrganization.LimitItemDeletion = LimitItemDeletion; - existingOrganization.AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems; - return existingOrganization; - } + LimitCollectionCreation = LimitCollectionCreation, + LimitCollectionDeletion = LimitCollectionDeletion, + LimitItemDeletion = LimitItemDeletion, + AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems + }; } diff --git a/src/Api/Models/Request/Organizations/OrganizationCreateLicenseRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationCreateLicenseRequestModel.cs index 5ee7a632a6..13e0371c51 100644 --- a/src/Api/Models/Request/Organizations/OrganizationCreateLicenseRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationCreateLicenseRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Core.Utilities; diff --git a/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs index 896b5799e0..0dd2e892ac 100644 --- a/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Enums; using Bit.Core.Utilities; diff --git a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs index 571f69c1ef..1278cd5b53 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Models.Request.Organizations; diff --git a/src/Api/Models/Request/PaymentRequestModel.cs b/src/Api/Models/Request/PaymentRequestModel.cs index eae1abfce2..4bc4a4d02b 100644 --- a/src/Api/Models/Request/PaymentRequestModel.cs +++ b/src/Api/Models/Request/PaymentRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Enums; namespace Bit.Api.Models.Request; diff --git a/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs b/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs index 318c40aa21..8630398e52 100644 --- a/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs +++ b/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.Models.Request; public class SubscriptionCancellationRequestModel { diff --git a/src/Api/Models/Request/UpdateDomainsRequestModel.cs b/src/Api/Models/Request/UpdateDomainsRequestModel.cs index 47c5d05dec..af53967267 100644 --- a/src/Api/Models/Request/UpdateDomainsRequestModel.cs +++ b/src/Api/Models/Request/UpdateDomainsRequestModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Api/Models/Response/CollectionResponseModel.cs b/src/Api/Models/Response/CollectionResponseModel.cs index 5ce8310117..10d56481c4 100644 --- a/src/Api/Models/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Response/CollectionResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Models.Data; @@ -20,6 +23,7 @@ public class CollectionResponseModel : ResponseModel Name = collection.Name; ExternalId = collection.ExternalId; Type = collection.Type; + DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; } public Guid Id { get; set; } @@ -27,6 +31,7 @@ public class CollectionResponseModel : ResponseModel public string Name { get; set; } public string ExternalId { get; set; } public CollectionType Type { get; set; } + public string DefaultUserCollectionEmail { get; set; } } /// @@ -44,6 +49,7 @@ public class CollectionDetailsResponseModel : CollectionResponseModel ReadOnly = collectionDetails.ReadOnly; HidePasswords = collectionDetails.HidePasswords; Manage = collectionDetails.Manage; + DefaultUserCollectionEmail = collectionDetails.DefaultUserCollectionEmail; } public bool ReadOnly { get; set; } diff --git a/src/Api/Models/Response/ConfigResponseModel.cs b/src/Api/Models/Response/ConfigResponseModel.cs index 4571089295..20bc3f9e10 100644 --- a/src/Api/Models/Response/ConfigResponseModel.cs +++ b/src/Api/Models/Response/ConfigResponseModel.cs @@ -1,4 +1,6 @@ -using Bit.Core; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Services; @@ -43,8 +45,7 @@ public class ConfigResponseModel : ResponseModel Sso = globalSettings.BaseServiceUri.Sso }; FeatureStates = featureService.GetAll(); - var webPushEnabled = FeatureStates.TryGetValue(FeatureFlagKeys.WebPush, out var webPushEnabledValue) ? (bool)webPushEnabledValue : false; - Push = PushSettings.Build(webPushEnabled, globalSettings); + Push = PushSettings.Build(globalSettings); Settings = new ServerSettingsResponseModel { DisableUserRegistration = globalSettings.DisableUserRegistration @@ -73,9 +74,9 @@ public class PushSettings public PushTechnologyType PushTechnology { get; private init; } public string VapidPublicKey { get; private init; } - public static PushSettings Build(bool webPushEnabled, IGlobalSettings globalSettings) + public static PushSettings Build(IGlobalSettings globalSettings) { - var vapidPublicKey = webPushEnabled ? globalSettings.WebPush.VapidPublicKey : null; + var vapidPublicKey = globalSettings.WebPush.VapidPublicKey; var pushTechnology = vapidPublicKey != null ? PushTechnologyType.WebPush : PushTechnologyType.SignalR; return new() { diff --git a/src/Api/Models/Response/DeviceResponseModel.cs b/src/Api/Models/Response/DeviceResponseModel.cs index 44f8a16db2..4acaeea793 100644 --- a/src/Api/Models/Response/DeviceResponseModel.cs +++ b/src/Api/Models/Response/DeviceResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Utilities; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; diff --git a/src/Api/Models/Response/DomainsResponseModel.cs b/src/Api/Models/Response/DomainsResponseModel.cs index 5b6b4e59c8..82abddb4e4 100644 --- a/src/Api/Models/Response/DomainsResponseModel.cs +++ b/src/Api/Models/Response/DomainsResponseModel.cs @@ -1,14 +1,17 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; namespace Bit.Api.Models.Response; -public class DomainsResponseModel : ResponseModel +public class DomainsResponseModel() : ResponseModel("domains") { public DomainsResponseModel(User user, bool excluded = true) - : base("domains") + : this() { if (user == null) { @@ -35,13 +38,13 @@ public class DomainsResponseModel : ResponseModel public IEnumerable GlobalEquivalentDomains { get; set; } - public class GlobalDomains + public class GlobalDomains() { public GlobalDomains( GlobalEquivalentDomainsType globalDomain, IEnumerable domains, IEnumerable excludedDomains, - bool excluded) + bool excluded) : this() { Type = (byte)globalDomain; Domains = domains; diff --git a/src/Api/Models/Response/KeysResponseModel.cs b/src/Api/Models/Response/KeysResponseModel.cs index 2f7e5e7304..cfc1a6a0a1 100644 --- a/src/Api/Models/Response/KeysResponseModel.cs +++ b/src/Api/Models/Response/KeysResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Models.Api; namespace Bit.Api.Models.Response; diff --git a/src/Api/Models/Response/ListResponseModel.cs b/src/Api/Models/Response/ListResponseModel.cs index ecfe0a7e19..746e6c197b 100644 --- a/src/Api/Models/Response/ListResponseModel.cs +++ b/src/Api/Models/Response/ListResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; namespace Bit.Api.Models.Response; diff --git a/src/Api/Models/Response/PaymentResponseModel.cs b/src/Api/Models/Response/PaymentResponseModel.cs index 067ac969ec..1effe8bb1d 100644 --- a/src/Api/Models/Response/PaymentResponseModel.cs +++ b/src/Api/Models/Response/PaymentResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; namespace Bit.Api.Models.Response; diff --git a/src/Api/Models/Response/PlanResponseModel.cs b/src/Api/Models/Response/PlanResponseModel.cs index f48a06b4ec..6f2f752803 100644 --- a/src/Api/Models/Response/PlanResponseModel.cs +++ b/src/Api/Models/Response/PlanResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Models.Api; diff --git a/src/Api/Models/Response/ProfileResponseModel.cs b/src/Api/Models/Response/ProfileResponseModel.cs index 246b3c3227..cbdfaf0f16 100644 --- a/src/Api/Models/Response/ProfileResponseModel.cs +++ b/src/Api/Models/Response/ProfileResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Response; using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.Entities; diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index c7aae1dec2..7038bee2a7 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -1,4 +1,8 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models.Business; +using Bit.Core.Entities; using Bit.Core.Models.Api; using Bit.Core.Models.Business; using Bit.Core.Utilities; diff --git a/src/Api/Models/Response/TaxInfoResponseModel.cs b/src/Api/Models/Response/TaxInfoResponseModel.cs index c1cd51267e..67896abac6 100644 --- a/src/Api/Models/Response/TaxInfoResponseModel.cs +++ b/src/Api/Models/Response/TaxInfoResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Business; namespace Bit.Api.Models.Response; diff --git a/src/Api/Platform/Installations/Models/InstallationRequestModel.cs b/src/Api/Platform/Installations/Models/InstallationRequestModel.cs index 242701a66f..2237eedf92 100644 --- a/src/Api/Platform/Installations/Models/InstallationRequestModel.cs +++ b/src/Api/Platform/Installations/Models/InstallationRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Platform.Installations; using Bit.Core.Utilities; diff --git a/src/Api/Platform/Installations/Models/InstallationResponseModel.cs b/src/Api/Platform/Installations/Models/InstallationResponseModel.cs index 0be5795275..c48a453426 100644 --- a/src/Api/Platform/Installations/Models/InstallationResponseModel.cs +++ b/src/Api/Platform/Installations/Models/InstallationResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.Platform.Installations; namespace Bit.Api.Platform.Installations; diff --git a/src/Api/Platform/Push/Controllers/PushController.cs b/src/Api/Platform/Push/Controllers/PushController.cs index af24a7b2ca..14c0a20636 100644 --- a/src/Api/Platform/Push/Controllers/PushController.cs +++ b/src/Api/Platform/Push/Controllers/PushController.cs @@ -1,11 +1,14 @@ -using System.Diagnostics; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Diagnostics; using System.Text.Json; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Models.Api; -using Bit.Core.NotificationHub; using Bit.Core.Platform.Push; using Bit.Core.Platform.Push.Internal; +using Bit.Core.Platform.PushRegistration; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; diff --git a/src/Api/Program.cs b/src/Api/Program.cs index 2fd25eaefa..6023f51c6d 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -1,4 +1,7 @@ -using AspNetCoreRateLimit; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AspNetCoreRateLimit; using Bit.Core.Utilities; using Microsoft.IdentityModel.Tokens; diff --git a/src/Api/Public/Controllers/CollectionsController.cs b/src/Api/Public/Controllers/CollectionsController.cs index d4e0932caa..8615113906 100644 --- a/src/Api/Public/Controllers/CollectionsController.cs +++ b/src/Api/Public/Controllers/CollectionsController.cs @@ -1,10 +1,13 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.Models.Public.Request; using Bit.Api.Models.Public.Response; using Bit.Core.Context; +using Bit.Core.Enums; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; using Bit.Core.Repositories; -using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -17,18 +20,15 @@ public class CollectionsController : Controller private readonly ICollectionRepository _collectionRepository; private readonly IUpdateCollectionCommand _updateCollectionCommand; private readonly ICurrentContext _currentContext; - private readonly IApplicationCacheService _applicationCacheService; public CollectionsController( ICollectionRepository collectionRepository, IUpdateCollectionCommand updateCollectionCommand, - ICurrentContext currentContext, - IApplicationCacheService applicationCacheService) + ICurrentContext currentContext) { _collectionRepository = collectionRepository; _updateCollectionCommand = updateCollectionCommand; _currentContext = currentContext; - _applicationCacheService = applicationCacheService; } /// @@ -45,7 +45,8 @@ public class CollectionsController : Controller public async Task Get(Guid id) { (var collection, var access) = await _collectionRepository.GetByIdWithAccessAsync(id); - if (collection == null || collection.OrganizationId != _currentContext.OrganizationId) + if (collection == null || collection.OrganizationId != _currentContext.OrganizationId || + collection.Type == CollectionType.DefaultUserCollection) { return new NotFoundResult(); } @@ -64,7 +65,7 @@ public class CollectionsController : Controller [ProducesResponseType(typeof(ListResponseModel), (int)HttpStatusCode.OK)] public async Task List() { - var collections = await _collectionRepository.GetManyByOrganizationIdAsync( + var collections = await _collectionRepository.GetManySharedCollectionsByOrganizationIdAsync( _currentContext.OrganizationId.Value); // TODO: Get all CollectionGroup associations for the organization and marry them up here for the response. var collectionResponses = collections.Select(c => new CollectionResponseModel(c, null)); @@ -116,6 +117,12 @@ public class CollectionsController : Controller { return new NotFoundResult(); } + + if (collection.Type == CollectionType.DefaultUserCollection) + { + return new BadRequestObjectResult(new ErrorResponseModel("You cannot delete a collection with the type as DefaultUserCollection.")); + } + await _collectionRepository.DeleteAsync(collection); return new OkResult(); } diff --git a/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs b/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs index cd65a7cdf8..ad5d5e092b 100644 --- a/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs +++ b/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs @@ -29,6 +29,7 @@ public class AccessPoliciesController : Controller private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IUpdateServiceAccountGrantedPoliciesCommand _updateServiceAccountGrantedPoliciesCommand; private readonly IUserService _userService; + private readonly IEventService _eventService; private readonly IProjectServiceAccountsAccessPoliciesUpdatesQuery _projectServiceAccountsAccessPoliciesUpdatesQuery; private readonly IUpdateProjectServiceAccountsAccessPoliciesCommand @@ -47,7 +48,8 @@ public class AccessPoliciesController : Controller IServiceAccountGrantedPolicyUpdatesQuery serviceAccountGrantedPolicyUpdatesQuery, IProjectServiceAccountsAccessPoliciesUpdatesQuery projectServiceAccountsAccessPoliciesUpdatesQuery, IUpdateServiceAccountGrantedPoliciesCommand updateServiceAccountGrantedPoliciesCommand, - IUpdateProjectServiceAccountsAccessPoliciesCommand updateProjectServiceAccountsAccessPoliciesCommand) + IUpdateProjectServiceAccountsAccessPoliciesCommand updateProjectServiceAccountsAccessPoliciesCommand, + IEventService eventService) { _authorizationService = authorizationService; _userService = userService; @@ -61,6 +63,7 @@ public class AccessPoliciesController : Controller _serviceAccountGrantedPolicyUpdatesQuery = serviceAccountGrantedPolicyUpdatesQuery; _projectServiceAccountsAccessPoliciesUpdatesQuery = projectServiceAccountsAccessPoliciesUpdatesQuery; _updateProjectServiceAccountsAccessPoliciesCommand = updateProjectServiceAccountsAccessPoliciesCommand; + _eventService = eventService; } [HttpGet("/organizations/{id}/access-policies/people/potential-grantees")] @@ -186,7 +189,9 @@ public class AccessPoliciesController : Controller } var userId = _userService.GetProperUserId(User)!.Value; + var currentPolicies = await _accessPolicyRepository.GetPeoplePoliciesByGrantedServiceAccountIdAsync(peopleAccessPolicies.Id, userId); var results = await _accessPolicyRepository.ReplaceServiceAccountPeopleAsync(peopleAccessPolicies, userId); + await LogAccessPolicyServiceAccountChanges(currentPolicies, results, userId); return new ServiceAccountPeopleAccessPoliciesResponseModel(results, userId); } @@ -336,4 +341,39 @@ public class AccessPoliciesController : Controller userId, accessClient); return new ServiceAccountGrantedPoliciesPermissionDetailsResponseModel(results); } + + public async Task LogAccessPolicyServiceAccountChanges(IEnumerable currentPolicies, IEnumerable updatedPolicies, Guid userId) + { + foreach (var current in currentPolicies.OfType()) + { + if (!updatedPolicies.Any(r => r.Id == current.Id)) + { + await _eventService.LogServiceAccountGroupEventAsync(userId, current, EventType.ServiceAccount_GroupRemoved, _currentContext.IdentityClientType); + } + } + + foreach (var policy in updatedPolicies.OfType()) + { + if (!currentPolicies.Any(e => e.Id == policy.Id)) + { + await _eventService.LogServiceAccountGroupEventAsync(userId, policy, EventType.ServiceAccount_GroupAdded, _currentContext.IdentityClientType); + } + } + + foreach (var current in currentPolicies.OfType()) + { + if (!updatedPolicies.Any(r => r.Id == current.Id)) + { + await _eventService.LogServiceAccountPeopleEventAsync(userId, current, EventType.ServiceAccount_UserRemoved, _currentContext.IdentityClientType); + } + } + + foreach (var policy in updatedPolicies.OfType()) + { + if (!currentPolicies.Any(e => e.Id == policy.Id)) + { + await _eventService.LogServiceAccountPeopleEventAsync(userId, policy, EventType.ServiceAccount_UserAdded, _currentContext.IdentityClientType); + } + } + } } diff --git a/src/Api/SecretsManager/Controllers/ProjectsController.cs b/src/Api/SecretsManager/Controllers/ProjectsController.cs index a6929bc193..5dce032ece 100644 --- a/src/Api/SecretsManager/Controllers/ProjectsController.cs +++ b/src/Api/SecretsManager/Controllers/ProjectsController.cs @@ -1,6 +1,10 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -26,6 +30,7 @@ public class ProjectsController : Controller private readonly IUpdateProjectCommand _updateProjectCommand; private readonly IDeleteProjectCommand _deleteProjectCommand; private readonly IAuthorizationService _authorizationService; + private readonly IEventService _eventService; public ProjectsController( ICurrentContext currentContext, @@ -35,7 +40,8 @@ public class ProjectsController : Controller ICreateProjectCommand createProjectCommand, IUpdateProjectCommand updateProjectCommand, IDeleteProjectCommand deleteProjectCommand, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + IEventService eventService) { _currentContext = currentContext; _userService = userService; @@ -45,6 +51,7 @@ public class ProjectsController : Controller _updateProjectCommand = updateProjectCommand; _deleteProjectCommand = deleteProjectCommand; _authorizationService = authorizationService; + _eventService = eventService; } [HttpGet("organizations/{organizationId}/projects")] @@ -86,6 +93,11 @@ public class ProjectsController : Controller var userId = _userService.GetProperUserId(User).Value; var result = await _createProjectCommand.CreateAsync(project, userId, _currentContext.IdentityClientType); + if (result != null) + { + await LogProjectEventAsync(project, EventType.Project_Created); + } + // Creating a project means you have read & write permission. return new ProjectResponseModel(result, true, true); } @@ -103,6 +115,10 @@ public class ProjectsController : Controller } var result = await _updateProjectCommand.UpdateAsync(updateRequest.ToProject(id)); + if (result != null) + { + await LogProjectEventAsync(project, EventType.Project_Edited); + } // Updating a project means you have read & write permission. return new ProjectResponseModel(result, true, true); @@ -133,6 +149,8 @@ public class ProjectsController : Controller throw new NotFoundException(); } + await LogProjectEventAsync(project, EventType.Project_Retrieved); + return new ProjectResponseModel(project, access.Read, access.Write); } @@ -172,9 +190,32 @@ public class ProjectsController : Controller } } - await _deleteProjectCommand.DeleteProjects(projectsToDelete); + if (projectsToDelete.Count > 0) + { + await _deleteProjectCommand.DeleteProjects(projectsToDelete); + await LogProjectsEventAsync(projectsToDelete, EventType.Project_Deleted); + } var responses = results.Select(r => new BulkDeleteResponseModel(r.Project.Id, r.Error)); return new ListResponseModel(responses); } + + + private async Task LogProjectsEventAsync(IEnumerable projects, EventType eventType) + { + var userId = _userService.GetProperUserId(User)!.Value; + + switch (_currentContext.IdentityClientType) + { + case IdentityClientType.ServiceAccount: + await _eventService.LogServiceAccountProjectsEventAsync(userId, projects, eventType); + break; + case IdentityClientType.User: + await _eventService.LogUserProjectsEventAsync(userId, projects, eventType); + break; + } + } + + private Task LogProjectEventAsync(Project project, EventType eventType) => + LogProjectsEventAsync(new[] { project }, eventType); } diff --git a/src/Api/SecretsManager/Controllers/SecretsController.cs b/src/Api/SecretsManager/Controllers/SecretsController.cs index dd653bb873..e263b9747d 100644 --- a/src/Api/SecretsManager/Controllers/SecretsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsController.cs @@ -1,10 +1,13 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Identity; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Entities; @@ -109,7 +112,7 @@ public class SecretsController : Controller } var result = await _createSecretCommand.CreateAsync(secret, accessPoliciesUpdates); - + await LogSecretEventAsync(secret, EventType.Secret_Created); // Creating a secret means you have read & write permission. return new SecretResponseModel(result, true, true); } @@ -135,10 +138,7 @@ public class SecretsController : Controller throw new NotFoundException(); } - if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) - { - await _eventService.LogServiceAccountSecretEventAsync(userId, secret, EventType.Secret_Retrieved); - } + await LogSecretEventAsync(secret, EventType.Secret_Retrieved); return new SecretResponseModel(secret, access.Read, access.Write); } @@ -188,10 +188,10 @@ public class SecretsController : Controller { throw new NotFoundException(); } - } var result = await _updateSecretCommand.UpdateAsync(updatedSecret, accessPoliciesUpdates); + await LogSecretEventAsync(secret, EventType.Secret_Edited); // Updating a secret means you have read & write permission. return new SecretResponseModel(result, true, true); @@ -234,6 +234,7 @@ public class SecretsController : Controller await _deleteSecretCommand.DeleteSecrets(secretsToDelete); var responses = results.Select(r => new BulkDeleteResponseModel(r.Secret.Id, r.Error)); + await LogSecretsEventAsync(secretsToDelete, EventType.Secret_Deleted); return new ListResponseModel(responses); } @@ -253,7 +254,7 @@ public class SecretsController : Controller throw new NotFoundException(); } - await LogSecretsRetrievalAsync(secrets); + await LogSecretsEventAsync(secrets, EventType.Secret_Retrieved); var responses = secrets.Select(s => new BaseSecretResponseModel(s)); return new ListResponseModel(responses); @@ -290,18 +291,28 @@ public class SecretsController : Controller if (syncResult.HasChanges) { - await LogSecretsRetrievalAsync(syncResult.Secrets); + await LogSecretsEventAsync(syncResult.Secrets, EventType.Secret_Retrieved); } return new SecretsSyncResponseModel(syncResult.HasChanges, syncResult.Secrets); } - private async Task LogSecretsRetrievalAsync(IEnumerable secrets) + private async Task LogSecretsEventAsync(IEnumerable secrets, EventType eventType) { - if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) + var userId = _userService.GetProperUserId(User)!.Value; + + switch (_currentContext.IdentityClientType) { - var userId = _userService.GetProperUserId(User)!.Value; - await _eventService.LogServiceAccountSecretsEventAsync(userId, secrets, EventType.Secret_Retrieved); + case IdentityClientType.ServiceAccount: + await _eventService.LogServiceAccountSecretsEventAsync(userId, secrets, eventType); + break; + case IdentityClientType.User: + await _eventService.LogUserSecretsEventAsync(userId, secrets, eventType); + break; } } + + private Task LogSecretEventAsync(Secret secret, EventType eventType) => + LogSecretsEventAsync(new[] { secret }, eventType); + } diff --git a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs index 91d350b680..af162fe399 100644 --- a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Core.Exceptions; using Bit.Core.Models.Data; diff --git a/src/Api/SecretsManager/Controllers/SecretsManagerPortingController.cs b/src/Api/SecretsManager/Controllers/SecretsManagerPortingController.cs index 7599bd262b..7468586702 100644 --- a/src/Api/SecretsManager/Controllers/SecretsManagerPortingController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsManagerPortingController.cs @@ -1,4 +1,7 @@ -using Bit.Api.SecretsManager.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Context; using Bit.Core.Enums; diff --git a/src/Api/SecretsManager/Controllers/SecretsTrashController.cs b/src/Api/SecretsManager/Controllers/SecretsTrashController.cs index 19a84755d8..d791fa2341 100644 --- a/src/Api/SecretsManager/Controllers/SecretsTrashController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsTrashController.cs @@ -1,8 +1,12 @@ using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Auth.Identity; using Bit.Core.Context; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.SecretsManager.Commands.Trash.Interfaces; +using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -15,17 +19,23 @@ public class TrashController : Controller private readonly ISecretRepository _secretRepository; private readonly IEmptyTrashCommand _emptyTrashCommand; private readonly IRestoreTrashCommand _restoreTrashCommand; + private readonly IUserService _userService; + private readonly IEventService _eventService; public TrashController( ICurrentContext currentContext, ISecretRepository secretRepository, IEmptyTrashCommand emptyTrashCommand, - IRestoreTrashCommand restoreTrashCommand) + IRestoreTrashCommand restoreTrashCommand, + IUserService userService, + IEventService eventService) { _currentContext = currentContext; _secretRepository = secretRepository; _emptyTrashCommand = emptyTrashCommand; _restoreTrashCommand = restoreTrashCommand; + _userService = userService; + _eventService = eventService; } [HttpGet("secrets/{organizationId}/trash")] @@ -58,7 +68,9 @@ public class TrashController : Controller throw new UnauthorizedAccessException(); } + var deletedSecrets = await _secretRepository.GetManyTrashedSecretsByIds(ids); await _emptyTrashCommand.EmptyTrash(organizationId, ids); + await LogSecretsTrashEventAsync(deletedSecrets, EventType.Secret_Permanently_Deleted); } [HttpPost("secrets/{organizationId}/trash/restore")] @@ -75,5 +87,27 @@ public class TrashController : Controller } await _restoreTrashCommand.RestoreTrash(organizationId, ids); + await LogSecretsTrashEventAsync(ids, EventType.Secret_Restored); + } + + private async Task LogSecretsTrashEventAsync(IEnumerable secretIds, EventType eventType) + { + var secrets = await _secretRepository.GetManyByIds(secretIds); + await LogSecretsTrashEventAsync(secrets, eventType); + } + + private async Task LogSecretsTrashEventAsync(IEnumerable secrets, EventType eventType) + { + var userId = _userService.GetProperUserId(User)!.Value; + + switch (_currentContext.IdentityClientType) + { + case IdentityClientType.ServiceAccount: + await _eventService.LogServiceAccountSecretsEventAsync(userId, secrets, eventType); + break; + case IdentityClientType.User: + await _eventService.LogUserSecretsEventAsync(userId, secrets, eventType); + break; + } } } diff --git a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs index 96c6c60528..0afdc3a1bf 100644 --- a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs +++ b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Billing.Pricing; @@ -39,6 +42,8 @@ public class ServiceAccountsController : Controller private readonly IDeleteServiceAccountsCommand _deleteServiceAccountsCommand; private readonly IRevokeAccessTokensCommand _revokeAccessTokensCommand; private readonly IPricingClient _pricingClient; + private readonly IEventService _eventService; + private readonly IOrganizationUserRepository _organizationUserRepository; public ServiceAccountsController( ICurrentContext currentContext, @@ -55,7 +60,9 @@ public class ServiceAccountsController : Controller IUpdateServiceAccountCommand updateServiceAccountCommand, IDeleteServiceAccountsCommand deleteServiceAccountsCommand, IRevokeAccessTokensCommand revokeAccessTokensCommand, - IPricingClient pricingClient) + IPricingClient pricingClient, + IEventService eventService, + IOrganizationUserRepository organizationUserRepository) { _currentContext = currentContext; _userService = userService; @@ -72,6 +79,8 @@ public class ServiceAccountsController : Controller _pricingClient = pricingClient; _createAccessTokenCommand = createAccessTokenCommand; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; + _eventService = eventService; + _organizationUserRepository = organizationUserRepository; } [HttpGet("/organizations/{organizationId}/service-accounts")] @@ -136,8 +145,15 @@ public class ServiceAccountsController : Controller } var userId = _userService.GetProperUserId(User).Value; + var result = - await _createServiceAccountCommand.CreateAsync(createRequest.ToServiceAccount(organizationId), userId); + await _createServiceAccountCommand.CreateAsync(serviceAccount, userId); + + if (result != null) + { + await _eventService.LogServiceAccountEventAsync(userId, [serviceAccount], EventType.ServiceAccount_Created, _currentContext.IdentityClientType); + } + return new ServiceAccountResponseModel(result); } @@ -194,6 +210,9 @@ public class ServiceAccountsController : Controller } await _deleteServiceAccountsCommand.DeleteServiceAccounts(serviceAccountsToDelete); + var userId = _userService.GetProperUserId(User)!.Value; + await _eventService.LogServiceAccountEventAsync(userId, serviceAccountsToDelete, EventType.ServiceAccount_Deleted, _currentContext.IdentityClientType); + var responses = results.Select(r => new BulkDeleteResponseModel(r.ServiceAccount.Id, r.Error)); return new ListResponseModel(responses); } diff --git a/src/Api/SecretsManager/Models/Request/AccessTokenCreateRequestModel.cs b/src/Api/SecretsManager/Models/Request/AccessTokenCreateRequestModel.cs index 2d961ad824..20014b6730 100644 --- a/src/Api/SecretsManager/Models/Request/AccessTokenCreateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/AccessTokenCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/GetSecretsRequestModel.cs b/src/Api/SecretsManager/Models/Request/GetSecretsRequestModel.cs index 5eec3a7a6c..84238ae149 100644 --- a/src/Api/SecretsManager/Models/Request/GetSecretsRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/GetSecretsRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.SecretsManager.Models.Request; public class GetSecretsRequestModel : IValidatableObject diff --git a/src/Api/SecretsManager/Models/Request/PeopleAccessPoliciesRequestModel.cs b/src/Api/SecretsManager/Models/Request/PeopleAccessPoliciesRequestModel.cs index 1ce74aca3c..d6f1396ed5 100644 --- a/src/Api/SecretsManager/Models/Request/PeopleAccessPoliciesRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/PeopleAccessPoliciesRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.SecretsManager.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.SecretsManager.Utilities; using Bit.Core.Exceptions; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Api/SecretsManager/Models/Request/ProjectCreateRequestModel.cs b/src/Api/SecretsManager/Models/Request/ProjectCreateRequestModel.cs index 3014ecdf82..73b8f0cdc9 100644 --- a/src/Api/SecretsManager/Models/Request/ProjectCreateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/ProjectCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/ProjectUpdateRequestModel.cs b/src/Api/SecretsManager/Models/Request/ProjectUpdateRequestModel.cs index 176b6cc598..a582e87d75 100644 --- a/src/Api/SecretsManager/Models/Request/ProjectUpdateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/ProjectUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/RequestSMAccessRequestModel.cs b/src/Api/SecretsManager/Models/Request/RequestSMAccessRequestModel.cs index 1f05bad933..b3a9e2a140 100644 --- a/src/Api/SecretsManager/Models/Request/RequestSMAccessRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/RequestSMAccessRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.SecretsManager.Models.Request; diff --git a/src/Api/SecretsManager/Models/Request/RevokeAccessTokensRequest.cs b/src/Api/SecretsManager/Models/Request/RevokeAccessTokensRequest.cs index ecced7a5cd..5dcce209fc 100644 --- a/src/Api/SecretsManager/Models/Request/RevokeAccessTokensRequest.cs +++ b/src/Api/SecretsManager/Models/Request/RevokeAccessTokensRequest.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; public class RevokeAccessTokensRequest { diff --git a/src/Api/SecretsManager/Models/Request/SMImportRequestModel.cs b/src/Api/SecretsManager/Models/Request/SMImportRequestModel.cs index a63e2c180d..a9ee6023bc 100644 --- a/src/Api/SecretsManager/Models/Request/SMImportRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/SMImportRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Commands.Porting; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/SecretCreateRequestModel.cs b/src/Api/SecretsManager/Models/Request/SecretCreateRequestModel.cs index 6c0d41c2dd..20cdcf005d 100644 --- a/src/Api/SecretsManager/Models/Request/SecretCreateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/SecretCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs b/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs index 7d298bfa0f..b95bc9e500 100644 --- a/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/SerivceAccountUpdateRequestModel.cs b/src/Api/SecretsManager/Models/Request/SerivceAccountUpdateRequestModel.cs index 017749725f..1c50ac059c 100644 --- a/src/Api/SecretsManager/Models/Request/SerivceAccountUpdateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/SerivceAccountUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/ServiceAccountCreateRequestModel.cs b/src/Api/SecretsManager/Models/Request/ServiceAccountCreateRequestModel.cs index 6771669209..ba27189281 100644 --- a/src/Api/SecretsManager/Models/Request/ServiceAccountCreateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/ServiceAccountCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Response/AccessTokenResponseModel.cs b/src/Api/SecretsManager/Models/Response/AccessTokenResponseModel.cs index 72f9fcac64..50fee5f976 100644 --- a/src/Api/SecretsManager/Models/Response/AccessTokenResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/AccessTokenResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; namespace Bit.Api.SecretsManager.Models.Response; diff --git a/src/Api/SecretsManager/Models/Response/BaseSecretResponseModel.cs b/src/Api/SecretsManager/Models/Response/BaseSecretResponseModel.cs index 0579baec07..26425b53d0 100644 --- a/src/Api/SecretsManager/Models/Response/BaseSecretResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/BaseSecretResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; namespace Bit.Api.SecretsManager.Models.Response; diff --git a/src/Api/SecretsManager/Models/Response/PotentialGranteeResponseModel.cs b/src/Api/SecretsManager/Models/Response/PotentialGranteeResponseModel.cs index 002ba1525b..9bc274430d 100644 --- a/src/Api/SecretsManager/Models/Response/PotentialGranteeResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/PotentialGranteeResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Api/SecretsManager/Models/Response/ProjectResponseModel.cs b/src/Api/SecretsManager/Models/Response/ProjectResponseModel.cs index c2a1b9a09f..f7b0bb5c9c 100644 --- a/src/Api/SecretsManager/Models/Response/ProjectResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/ProjectResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Api/SecretsManager/Models/Response/SMExportResponseModel.cs b/src/Api/SecretsManager/Models/Response/SMExportResponseModel.cs index 6d83117c32..c361e8abc3 100644 --- a/src/Api/SecretsManager/Models/Response/SMExportResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/SMExportResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; namespace Bit.Api.SecretsManager.Models.Response; diff --git a/src/Api/SecretsManager/Models/Response/SMImportResponseModel.cs b/src/Api/SecretsManager/Models/Response/SMImportResponseModel.cs index 25d9956c43..46e7422c77 100644 --- a/src/Api/SecretsManager/Models/Response/SMImportResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/SMImportResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Commands.Porting; namespace Bit.Api.SecretsManager.Models.Response; diff --git a/src/Api/SecretsManager/Models/Response/SecretWithProjectsListResponseModel.cs b/src/Api/SecretsManager/Models/Response/SecretWithProjectsListResponseModel.cs index 29dffa8e63..4f1e572a36 100644 --- a/src/Api/SecretsManager/Models/Response/SecretWithProjectsListResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/SecretWithProjectsListResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Api/SecretsManager/Models/Response/ServiceAccountResponseModel.cs b/src/Api/SecretsManager/Models/Response/ServiceAccountResponseModel.cs index 570c91fd08..17724d8fa0 100644 --- a/src/Api/SecretsManager/Models/Response/ServiceAccountResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/ServiceAccountResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index c2a75c9278..cc50a1b362 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -5,7 +5,7 @@ using Bit.Core.Settings; using AspNetCoreRateLimit; using Stripe; using Bit.Core.Utilities; -using IdentityModel; +using Duende.IdentityModel; using System.Globalization; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request; @@ -13,7 +13,6 @@ using Bit.Api.KeyManagement.Validators; using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models.Request; using Bit.Core.Auth.Entities; -using Bit.Core.IdentityServer; using Bit.SharedWeb.Health; using Microsoft.IdentityModel.Logging; using Microsoft.OpenApi.Models; @@ -27,13 +26,16 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Api.Auth.Models.Request.WebAuthn; -using Bit.Api.Billing; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Tools.ImportFeatures; using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Tools.SendFeatures; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Auth.Identity; +using Bit.Core.Enums; + #if !OSS using Bit.Commercial.Core.SecretsManager; @@ -104,40 +106,40 @@ public class Startup services.AddCustomIdentityServices(globalSettings); services.AddIdentityAuthenticationServices(globalSettings, Environment, config => { - config.AddPolicy("Application", policy => + config.AddPolicy(Policies.Application, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.AuthenticationMethod, "Application", "external"); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.Api); }); - config.AddPolicy("Web", policy => + config.AddPolicy(Policies.Web, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.AuthenticationMethod, "Application", "external"); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.Api); - policy.RequireClaim(JwtClaimTypes.ClientId, "web"); + policy.RequireClaim(JwtClaimTypes.ClientId, BitwardenClient.Web); }); - config.AddPolicy("Push", policy => + config.AddPolicy(Policies.Push, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiPush); }); - config.AddPolicy("Licensing", policy => + config.AddPolicy(Policies.Licensing, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiLicensing); }); - config.AddPolicy("Organization", policy => + config.AddPolicy(Policies.Organization, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiOrganization); }); - config.AddPolicy("Installation", policy => + config.AddPolicy(Policies.Installation, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiInstallation); }); - config.AddPolicy("Secrets", policy => + config.AddPolicy(Policies.Secrets, policy => { policy.RequireAuthenticatedUser(); policy.RequireAssertion(ctx => ctx.User.HasClaim(c => @@ -145,6 +147,12 @@ public class Startup (c.Value.Contains(ApiScopes.Api) || c.Value.Contains(ApiScopes.ApiSecrets)) )); }); + config.AddPolicy(Policies.Send, configurePolicy: policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiSendAccess); + policy.RequireClaim(Claims.SendAccessClaims.SendId); + }); }); services.AddScoped(); @@ -184,7 +192,6 @@ public class Startup services.AddImportServices(); services.AddPhishingDomainServices(globalSettings); - services.AddBillingQueries(); services.AddSendServices(); // Authorization Handlers @@ -212,7 +219,7 @@ public class Startup config.Conventions.Add(new PublicApiControllersModelConvention()); }); - services.AddSwagger(globalSettings); + services.AddSwagger(globalSettings, Environment); Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted); services.AddHostedService(); diff --git a/src/Api/Tools/Authorization/VaultExportAuthorizationHandler.cs b/src/Api/Tools/Authorization/VaultExportAuthorizationHandler.cs index 337a0dc1e5..8968ecfee8 100644 --- a/src/Api/Tools/Authorization/VaultExportAuthorizationHandler.cs +++ b/src/Api/Tools/Authorization/VaultExportAuthorizationHandler.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.Context; using Bit.Core.Enums; using Microsoft.AspNetCore.Authorization; diff --git a/src/Api/Tools/Controllers/ImportCiphersController.cs b/src/Api/Tools/Controllers/ImportCiphersController.cs index 817105c74b..88028420b7 100644 --- a/src/Api/Tools/Controllers/ImportCiphersController.cs +++ b/src/Api/Tools/Controllers/ImportCiphersController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Tools.Models.Request.Accounts; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Tools.Models.Request.Accounts; using Bit.Api.Tools.Models.Request.Organizations; using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core.Context; @@ -60,7 +63,7 @@ public class ImportCiphersController : Controller } [HttpPost("import-organization")] - public async Task PostImport([FromQuery] string organizationId, + public async Task PostImportOrganization([FromQuery] string organizationId, [FromBody] ImportOrganizationCiphersRequestModel model) { if (!_globalSettings.SelfHosted && diff --git a/src/Api/Tools/Controllers/OrganizationExportController.cs b/src/Api/Tools/Controllers/OrganizationExportController.cs index 520746f139..dd039bc4a5 100644 --- a/src/Api/Tools/Controllers/OrganizationExportController.cs +++ b/src/Api/Tools/Controllers/OrganizationExportController.cs @@ -1,13 +1,12 @@ using Bit.Api.Tools.Authorization; using Bit.Api.Tools.Models.Response; +using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; -using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Vault.Queries; -using Bit.Core.Vault.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -17,36 +16,27 @@ namespace Bit.Api.Tools.Controllers; [Authorize("Application")] public class OrganizationExportController : Controller { - private readonly ICurrentContext _currentContext; private readonly IUserService _userService; - private readonly ICollectionService _collectionService; - private readonly ICipherService _cipherService; private readonly GlobalSettings _globalSettings; - private readonly IFeatureService _featureService; private readonly IAuthorizationService _authorizationService; private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly ICollectionRepository _collectionRepository; + private readonly IFeatureService _featureService; public OrganizationExportController( - ICurrentContext currentContext, - ICipherService cipherService, - ICollectionService collectionService, IUserService userService, GlobalSettings globalSettings, - IFeatureService featureService, IAuthorizationService authorizationService, IOrganizationCiphersQuery organizationCiphersQuery, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + IFeatureService featureService) { - _currentContext = currentContext; - _cipherService = cipherService; - _collectionService = collectionService; _userService = userService; _globalSettings = globalSettings; - _featureService = featureService; _authorizationService = authorizationService; _organizationCiphersQuery = organizationCiphersQuery; _collectionRepository = collectionRepository; + _featureService = featureService; } [HttpGet("export")] @@ -54,23 +44,47 @@ public class OrganizationExportController : Controller { var canExportAll = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId), VaultExportOperations.ExportWholeVault); - if (canExportAll.Succeeded) - { - var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); - var allCollections = await _collectionRepository.GetManyByOrganizationIdAsync(organizationId); - return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, _globalSettings)); - } - var canExportManaged = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId), VaultExportOperations.ExportManagedCollections); + var createDefaultLocationEnabled = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation); + + if (canExportAll.Succeeded) + { + if (createDefaultLocationEnabled) + { + var allOrganizationCiphers = + await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections( + organizationId); + + var allCollections = await _collectionRepository + .GetManySharedCollectionsByOrganizationIdAsync( + organizationId); + + + return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, + _globalSettings)); + } + else + { + var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); + + var allCollections = await _collectionRepository.GetManyByOrganizationIdAsync(organizationId); + + return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, + _globalSettings)); + } + } + if (canExportManaged.Succeeded) { var userId = _userService.GetProperUserId(User)!.Value; var allUserCollections = await _collectionRepository.GetManyByUserIdAsync(userId); - var managedOrgCollections = allUserCollections.Where(c => c.OrganizationId == organizationId && c.Manage).ToList(); - var managedCiphers = - await _organizationCiphersQuery.GetOrganizationCiphersByCollectionIds(organizationId, managedOrgCollections.Select(c => c.Id)); + var managedOrgCollections = + allUserCollections.Where(c => c.OrganizationId == organizationId && c.Manage).ToList(); + + var managedCiphers = await _organizationCiphersQuery.GetOrganizationCiphersByCollectionIds(organizationId, + managedOrgCollections.Select(c => c.Id)); return Ok(new OrganizationExportResponseModel(managedCiphers, managedOrgCollections, _globalSettings)); } diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index a51ec942cf..c02e9b0c20 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Azure.Messaging.EventGrid; using Bit.Api.Models.Response; using Bit.Api.Tools.Models.Request; @@ -189,7 +192,7 @@ public class SendsController : Controller } [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var userId = _userService.GetProperUserId(User).Value; var sends = await _sendRepository.GetManyByUserIdAsync(userId); diff --git a/src/Api/Tools/Models/Request/Accounts/ImportCiphersRequestModel.cs b/src/Api/Tools/Models/Request/Accounts/ImportCiphersRequestModel.cs index 354d73ad04..8330e4fc54 100644 --- a/src/Api/Tools/Models/Request/Accounts/ImportCiphersRequestModel.cs +++ b/src/Api/Tools/Models/Request/Accounts/ImportCiphersRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Vault.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Vault.Models.Request; namespace Bit.Api.Tools.Models.Request.Accounts; diff --git a/src/Api/Tools/Models/Request/Organizations/ImportOrganizationCiphersRequestModel.cs b/src/Api/Tools/Models/Request/Organizations/ImportOrganizationCiphersRequestModel.cs index 8c88be136a..45f8dfdffd 100644 --- a/src/Api/Tools/Models/Request/Organizations/ImportOrganizationCiphersRequestModel.cs +++ b/src/Api/Tools/Models/Request/Organizations/ImportOrganizationCiphersRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Request; using Bit.Api.Vault.Models.Request; namespace Bit.Api.Tools.Models.Request.Organizations; diff --git a/src/Api/Tools/Models/Request/SendAccessRequestModel.cs b/src/Api/Tools/Models/Request/SendAccessRequestModel.cs index c29577c2d0..15745ac855 100644 --- a/src/Api/Tools/Models/Request/SendAccessRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendAccessRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Tools.Models.Request; diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs index 5b3fd7ba31..a38257db60 100644 --- a/src/Api/Tools/Models/Request/SendRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json; using Bit.Core.Exceptions; using Bit.Core.Tools.Entities; diff --git a/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs index 5fd7e821cf..48fb96807e 100644 --- a/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs +++ b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Response; using Bit.Core.Entities; using Bit.Core.Models.Api; diff --git a/src/Api/Tools/Models/Response/SendAccessResponseModel.cs b/src/Api/Tools/Models/Response/SendAccessResponseModel.cs index a3bb0f8bc0..b544862fcd 100644 --- a/src/Api/Tools/Models/Response/SendAccessResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendAccessResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Models.Api; using Bit.Core.Settings; using Bit.Core.Tools.Entities; diff --git a/src/Api/Tools/Models/Response/SendFileDownloadDataResponseModel.cs b/src/Api/Tools/Models/Response/SendFileDownloadDataResponseModel.cs index 47d5d3a840..8e20062301 100644 --- a/src/Api/Tools/Models/Response/SendFileDownloadDataResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendFileDownloadDataResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; namespace Bit.Api.Tools.Models.Response; diff --git a/src/Api/Tools/Models/Response/SendFileUploadDataResponseModel.cs b/src/Api/Tools/Models/Response/SendFileUploadDataResponseModel.cs index aee80de220..4f263b7e9c 100644 --- a/src/Api/Tools/Models/Response/SendFileUploadDataResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendFileUploadDataResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.Models.Api; namespace Bit.Api.Tools.Models.Response; diff --git a/src/Api/Tools/Models/Response/SendResponseModel.cs b/src/Api/Tools/Models/Response/SendResponseModel.cs index 2ea217fd67..17a70cd2db 100644 --- a/src/Api/Tools/Models/Response/SendResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Models.Api; using Bit.Core.Settings; using Bit.Core.Tools.Entities; diff --git a/src/Api/Tools/Models/SendFileModel.cs b/src/Api/Tools/Models/SendFileModel.cs index 4af5b6ed6c..88deef4b13 100644 --- a/src/Api/Tools/Models/SendFileModel.cs +++ b/src/Api/Tools/Models/SendFileModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; diff --git a/src/Api/Tools/Models/SendTextModel.cs b/src/Api/Tools/Models/SendTextModel.cs index 274e0d537a..fdc547c522 100644 --- a/src/Api/Tools/Models/SendTextModel.cs +++ b/src/Api/Tools/Models/SendTextModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Tools.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; namespace Bit.Api.Tools.Models; diff --git a/src/Api/Utilities/ApiExplorerGroupConvention.cs b/src/Api/Utilities/ApiExplorerGroupConvention.cs index 42b1c8d6e7..e196b74617 100644 --- a/src/Api/Utilities/ApiExplorerGroupConvention.cs +++ b/src/Api/Utilities/ApiExplorerGroupConvention.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Mvc.ApplicationModels; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Mvc.ApplicationModels; namespace Bit.Api.Utilities; diff --git a/src/Api/Utilities/ApiHelpers.cs b/src/Api/Utilities/ApiHelpers.cs index 2c6dc8b73b..3c0701b1bd 100644 --- a/src/Api/Utilities/ApiHelpers.cs +++ b/src/Api/Utilities/ApiHelpers.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Azure.Messaging.EventGrid; using Azure.Messaging.EventGrid.SystemEvents; using Bit.Core.Exceptions; diff --git a/src/Api/Utilities/EnumMatchesAttribute.cs b/src/Api/Utilities/EnumMatchesAttribute.cs index a13b9d59d1..fb6a060170 100644 --- a/src/Api/Utilities/EnumMatchesAttribute.cs +++ b/src/Api/Utilities/EnumMatchesAttribute.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Utilities; diff --git a/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs b/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs index 15e8bb2954..91079d5040 100644 --- a/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs +++ b/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs @@ -1,4 +1,7 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text; using Bit.Api.Models.Public.Response; using Bit.Core.Billing; using Bit.Core.Exceptions; diff --git a/src/Api/Utilities/MultipartFormDataHelper.cs b/src/Api/Utilities/MultipartFormDataHelper.cs index a3eb64efb8..a2ead1368a 100644 --- a/src/Api/Utilities/MultipartFormDataHelper.cs +++ b/src/Api/Utilities/MultipartFormDataHelper.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Api.Tools.Models.Request; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.WebUtilities; diff --git a/src/Api/Utilities/PublicApiControllersModelConvention.cs b/src/Api/Utilities/PublicApiControllersModelConvention.cs index a7fabb0319..473485a67c 100644 --- a/src/Api/Utilities/PublicApiControllersModelConvention.cs +++ b/src/Api/Utilities/PublicApiControllersModelConvention.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Mvc.ApplicationModels; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Mvc.ApplicationModels; namespace Bit.Api.Utilities; diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index e6a20fe364..6af688f548 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -1,8 +1,6 @@ using Bit.Api.AdminConsole.Authorization; using Bit.Api.Tools.Authorization; -using Bit.Api.Vault.AuthorizationHandlers.Collections; -using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.PhishingDomainFeatures; using Bit.Core.PhishingDomainFeatures.Interfaces; using Bit.Core.Repositories; @@ -19,7 +17,7 @@ namespace Bit.Api.Utilities; public static class ServiceCollectionExtensions { - public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings) + public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment) { services.AddSwaggerGen(config => { @@ -33,7 +31,12 @@ public static class ServiceCollectionExtensions Url = new Uri("https://bitwarden.com"), Email = "support@bitwarden.com" }, - Description = "The Bitwarden public APIs.", + Description = """ + This schema documents the endpoints available to the Public API, which provides + organizations tools for managing members, collections, groups, event logs, and policies. + If you are looking for the Vault Management API, refer instead to + [this document](https://bitwarden.com/help/vault-management-api/). + """, License = new OpenApiLicense { Name = "GNU Affero General Public License v3.0", @@ -77,7 +80,7 @@ public static class ServiceCollectionExtensions config.DescribeAllParametersInCamelCase(); // config.UseReferencedDefinitionsForEnums(); - config.SchemaFilter(); + config.InitializeSwaggerFilters(environment); var apiFilePath = Path.Combine(AppContext.BaseDirectory, "Api.xml"); config.IncludeXmlComments(apiFilePath, true); @@ -104,14 +107,12 @@ public static class ServiceCollectionExtensions public static void AddAuthorizationHandlers(this IServiceCollection services) { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + // Admin Console authorization handlers + services.AddAdminConsoleAuthorizationHandlers(); } public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings) diff --git a/src/Api/Utilities/StringMatchesAttribute.cs b/src/Api/Utilities/StringMatchesAttribute.cs new file mode 100644 index 0000000000..28485aed40 --- /dev/null +++ b/src/Api/Utilities/StringMatchesAttribute.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Utilities; + +public class StringMatchesAttribute(params string[]? accepted) : ValidationAttribute +{ + public override bool IsValid(object? value) + { + if (value is not string str || + accepted == null || + accepted.Length == 0) + { + return false; + } + + return accepted.Contains(str); + } +} diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 5991d0babb..06c88ad9bb 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Azure.Messaging.EventGrid; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Models.Response; @@ -17,6 +20,7 @@ using Bit.Core.Settings; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault.Authorization.Permissions; +using Bit.Core.Vault.Commands.Interfaces; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Queries; @@ -45,6 +49,9 @@ public class CiphersController : Controller private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly IApplicationCacheService _applicationCacheService; private readonly ICollectionRepository _collectionRepository; + private readonly IArchiveCiphersCommand _archiveCiphersCommand; + private readonly IUnarchiveCiphersCommand _unarchiveCiphersCommand; + private readonly IFeatureService _featureService; public CiphersController( ICipherRepository cipherRepository, @@ -58,7 +65,10 @@ public class CiphersController : Controller GlobalSettings globalSettings, IOrganizationCiphersQuery organizationCiphersQuery, IApplicationCacheService applicationCacheService, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + IArchiveCiphersCommand archiveCiphersCommand, + IUnarchiveCiphersCommand unarchiveCiphersCommand, + IFeatureService featureService) { _cipherRepository = cipherRepository; _collectionCipherRepository = collectionCipherRepository; @@ -72,6 +82,9 @@ public class CiphersController : Controller _organizationCiphersQuery = organizationCiphersQuery; _applicationCacheService = applicationCacheService; _collectionRepository = collectionRepository; + _archiveCiphersCommand = archiveCiphersCommand; + _unarchiveCiphersCommand = unarchiveCiphersCommand; + _featureService = featureService; } [HttpGet("{id}")] @@ -105,7 +118,6 @@ public class CiphersController : Controller return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp); } - [HttpGet("{id}/full-details")] [HttpGet("{id}/details")] public async Task GetDetails(Guid id) { @@ -121,8 +133,15 @@ public class CiphersController : Controller return new CipherDetailsResponseModel(cipher, user, organizationAbilities, _globalSettings, collectionCiphers); } + [HttpGet("{id}/full-details")] + [Obsolete("This endpoint is deprecated. Use GET details method instead.")] + public async Task GetFullDetails(Guid id) + { + return await GetDetails(id); + } + [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var user = await _userService.GetUserByPrincipalAsync(User); var hasOrgs = _currentContext.Organizations.Count != 0; @@ -154,6 +173,7 @@ public class CiphersController : Controller { if (model.EncryptedFor != user.Id) { + _logger.LogError("Cipher was not encrypted for the current user. CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", user.Id, model.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -183,6 +203,7 @@ public class CiphersController : Controller { if (model.Cipher.EncryptedFor != user.Id) { + _logger.LogError("Cipher was not encrypted for the current user. CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", user.Id, model.Cipher.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -215,6 +236,7 @@ public class CiphersController : Controller { if (model.Cipher.EncryptedFor != userId) { + _logger.LogError("Cipher was not encrypted for the current user. CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", userId, model.Cipher.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -226,7 +248,6 @@ public class CiphersController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(Guid id, [FromBody] CipherRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -241,6 +262,7 @@ public class CiphersController : Controller { if (model.EncryptedFor != user.Id) { + _logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", id, user.Id, model.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -266,8 +288,14 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostPut(Guid id, [FromBody] CipherRequestModel model) + { + return await Put(id, model); + } + [HttpPut("{id}/admin")] - [HttpPost("{id}/admin")] public async Task PutAdmin(Guid id, [FromBody] CipherRequestModel model) { var userId = _userService.GetProperUserId(User).Value; @@ -278,6 +306,7 @@ public class CiphersController : Controller { if (model.EncryptedFor != userId) { + _logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", id, userId, model.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -299,15 +328,27 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}/admin")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostPutAdmin(Guid id, [FromBody] CipherRequestModel model) + { + return await PutAdmin(id, model); + } + [HttpGet("organization-details")] - public async Task> GetOrganizationCiphers(Guid organizationId) + public async Task> GetOrganizationCiphers(Guid organizationId, bool includeMemberItems = false) { if (!await CanAccessAllCiphersAsync(organizationId)) { throw new NotFoundException(); } - var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); + bool excludeDefaultUserCollections = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) && !includeMemberItems; + var allOrganizationCiphers = excludeDefaultUserCollections + ? + await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId) + : + await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); var allOrganizationCipherResponses = allOrganizationCiphers.Select(c => @@ -670,7 +711,6 @@ public class CiphersController : Controller } [HttpPut("{id}/partial")] - [HttpPost("{id}/partial")] public async Task PutPartial(Guid id, [FromBody] CipherPartialRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -686,8 +726,14 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}/partial")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostPartial(Guid id, [FromBody] CipherPartialRequestModel model) + { + return await PutPartial(id, model); + } + [HttpPut("{id}/share")] - [HttpPost("{id}/share")] public async Task PutShare(Guid id, [FromBody] CipherShareRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -703,6 +749,7 @@ public class CiphersController : Controller { if (model.Cipher.EncryptedFor != user.Id) { + _logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId} CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", id, user.Id, model.Cipher.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -722,8 +769,14 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}/share")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostShare(Guid id, [FromBody] CipherShareRequestModel model) + { + return await PutShare(id, model); + } + [HttpPut("{id}/collections")] - [HttpPost("{id}/collections")] public async Task PutCollections(Guid id, [FromBody] CipherCollectionsRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -748,8 +801,14 @@ public class CiphersController : Controller collectionCiphers); } + [HttpPost("{id}/collections")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostCollections(Guid id, [FromBody] CipherCollectionsRequestModel model) + { + return await PutCollections(id, model); + } + [HttpPut("{id}/collections_v2")] - [HttpPost("{id}/collections_v2")] public async Task PutCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -782,8 +841,14 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}/collections_v2")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model) + { + return await PutCollections_vNext(id, model); + } + [HttpPut("{id}/collections-admin")] - [HttpPost("{id}/collections-admin")] public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model) { var userId = _userService.GetProperUserId(User).Value; @@ -812,9 +877,19 @@ public class CiphersController : Controller return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp); } + [HttpPost("{id}/collections-admin")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model) + { + return await PutCollectionsAdmin(id, model); + } + [HttpPost("bulk-collections")] public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model) { + var userId = _userService.GetProperUserId(User).Value; + await _cipherService.ValidateBulkCollectionAssignmentAsync(model.CollectionIds, model.CipherIds, userId); + if (!await CanModifyCipherCollectionsAsync(model.OrganizationId, model.CipherIds) || !await CanEditItemsInCollections(model.OrganizationId, model.CollectionIds)) { @@ -831,8 +906,48 @@ public class CiphersController : Controller } } + [HttpPut("{id}/archive")] + [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] + public async Task PutArchive(Guid id) + { + var userId = _userService.GetProperUserId(User).Value; + + var archivedCipherOrganizationDetails = await _archiveCiphersCommand.ArchiveManyAsync([id], userId); + + if (archivedCipherOrganizationDetails.Count == 0) + { + throw new BadRequestException("Cipher was not archived. Ensure the provided ID is correct and you have permission to archive it."); + } + + return new CipherMiniResponseModel(archivedCipherOrganizationDetails.First(), _globalSettings, archivedCipherOrganizationDetails.First().OrganizationUseTotp); + } + + [HttpPut("archive")] + [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] + public async Task> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model) + { + if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) + { + throw new BadRequestException("You can only archive up to 500 items at a time."); + } + + var userId = _userService.GetProperUserId(User).Value; + + var cipherIdsToArchive = new HashSet(model.Ids); + + var archivedCiphers = await _archiveCiphersCommand.ArchiveManyAsync(cipherIdsToArchive, userId); + + if (archivedCiphers.Count == 0) + { + throw new BadRequestException("No ciphers were archived. Ensure the provided IDs are correct and you have permission to archive them."); + } + + var responses = archivedCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + + return new ListResponseModel(responses); + } + [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(Guid id) { var userId = _userService.GetProperUserId(User).Value; @@ -845,8 +960,14 @@ public class CiphersController : Controller await _cipherService.DeleteAsync(cipher, userId); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDelete(Guid id) + { + await Delete(id); + } + [HttpDelete("{id}/admin")] - [HttpPost("{id}/delete-admin")] public async Task DeleteAdmin(Guid id) { var userId = _userService.GetProperUserId(User).Value; @@ -860,8 +981,14 @@ public class CiphersController : Controller await _cipherService.DeleteAsync(cipher, userId, true); } + [HttpPost("{id}/delete-admin")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteAdmin(Guid id) + { + await DeleteAdmin(id); + } + [HttpDelete("")] - [HttpPost("delete")] public async Task DeleteMany([FromBody] CipherBulkDeleteRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) @@ -874,8 +1001,14 @@ public class CiphersController : Controller await _cipherService.DeleteManyAsync(model.Ids.Select(i => new Guid(i)), userId); } + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteMany([FromBody] CipherBulkDeleteRequestModel model) + { + await DeleteMany(model); + } + [HttpDelete("admin")] - [HttpPost("delete-admin")] public async Task DeleteManyAdmin([FromBody] CipherBulkDeleteRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) @@ -901,6 +1034,13 @@ public class CiphersController : Controller await _cipherService.DeleteManyAsync(cipherIds, userId, new Guid(model.OrganizationId), true); } + [HttpPost("delete-admin")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteManyAdmin([FromBody] CipherBulkDeleteRequestModel model) + { + await DeleteManyAdmin(model); + } + [HttpPut("{id}/delete")] public async Task PutDelete(Guid id) { @@ -917,14 +1057,14 @@ public class CiphersController : Controller public async Task PutDeleteAdmin(Guid id) { var userId = _userService.GetProperUserId(User).Value; - var cipher = await GetByIdAsync(id, userId); + var cipher = await GetByIdAsyncAdmin(id); if (cipher == null || !cipher.OrganizationId.HasValue || !await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) { throw new NotFoundException(); } - await _cipherService.SoftDeleteAsync(cipher, userId, true); + await _cipherService.SoftDeleteAsync(new CipherDetails(cipher), userId, true); } [HttpPut("delete")] @@ -964,6 +1104,47 @@ public class CiphersController : Controller await _cipherService.SoftDeleteManyAsync(cipherIds, userId, new Guid(model.OrganizationId), true); } + [HttpPut("{id}/unarchive")] + [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] + public async Task PutUnarchive(Guid id) + { + var userId = _userService.GetProperUserId(User).Value; + + var unarchivedCipherDetails = await _unarchiveCiphersCommand.UnarchiveManyAsync([id], userId); + + if (unarchivedCipherDetails.Count == 0) + { + throw new BadRequestException("Cipher was not unarchived. Ensure the provided ID is correct and you have permission to archive it."); + } + + return new CipherMiniResponseModel(unarchivedCipherDetails.First(), _globalSettings, unarchivedCipherDetails.First().OrganizationUseTotp); + } + + [HttpPut("unarchive")] + [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] + public async Task> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model) + { + if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) + { + throw new BadRequestException("You can only unarchive up to 500 items at a time."); + } + + var userId = _userService.GetProperUserId(User).Value; + + var cipherIdsToUnarchive = new HashSet(model.Ids); + + var unarchivedCipherOrganizationDetails = await _unarchiveCiphersCommand.UnarchiveManyAsync(cipherIdsToUnarchive, userId); + + if (unarchivedCipherOrganizationDetails.Count == 0) + { + throw new BadRequestException("Ciphers were not unarchived. Ensure the provided ID is correct and you have permission to archive it."); + } + + var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + + return new ListResponseModel(responses); + } + [HttpPut("{id}/restore")] public async Task PutRestore(Guid id) { @@ -986,14 +1167,14 @@ public class CiphersController : Controller public async Task PutRestoreAdmin(Guid id) { var userId = _userService.GetProperUserId(User).Value; - var cipher = await GetByIdAsync(id, userId); + var cipher = await GetByIdAsyncAdmin(id); if (cipher == null || !cipher.OrganizationId.HasValue || !await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) { throw new NotFoundException(); } - await _cipherService.RestoreAsync(cipher, userId, true); + await _cipherService.RestoreAsync(new CipherDetails(cipher), userId, true); return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp); } @@ -1041,7 +1222,6 @@ public class CiphersController : Controller } [HttpPut("move")] - [HttpPost("move")] public async Task MoveMany([FromBody] CipherBulkMoveRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) @@ -1054,8 +1234,14 @@ public class CiphersController : Controller string.IsNullOrWhiteSpace(model.FolderId) ? (Guid?)null : new Guid(model.FolderId), userId); } + [HttpPost("move")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostMoveMany([FromBody] CipherBulkMoveRequestModel model) + { + await MoveMany(model); + } + [HttpPut("share")] - [HttpPost("share")] public async Task> PutShareMany([FromBody] CipherBulkShareRequestModel model) { var organizationId = new Guid(model.Ciphers.First().OrganizationId); @@ -1074,6 +1260,7 @@ public class CiphersController : Controller { if (cipher.EncryptedFor.HasValue && cipher.EncryptedFor.Value != userId) { + _logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -1102,8 +1289,15 @@ public class CiphersController : Controller return new ListResponseModel(response); } + [HttpPost("share")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task> PostShareMany([FromBody] CipherBulkShareRequestModel model) + { + return await PutShareMany(model); + } + [HttpPost("purge")] - public async Task PostPurge([FromBody] SecretVerificationRequestModel model, string organizationId = null) + public async Task PostPurge([FromBody] SecretVerificationRequestModel model, Guid? organizationId = null) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -1118,24 +1312,22 @@ public class CiphersController : Controller throw new BadRequestException(ModelState); } - // Check if the user is claimed by any organization. - if (await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) - { - throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details."); - } - - if (string.IsNullOrWhiteSpace(organizationId)) + if (organizationId == null) { + // Check if the user is claimed by any organization. + if (await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) + { + throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details."); + } await _cipherRepository.DeleteByUserIdAsync(user.Id); } else { - var orgId = new Guid(organizationId); - if (!await _currentContext.EditAnyCollection(orgId)) + if (!await _currentContext.EditAnyCollection(organizationId!.Value)) { throw new NotFoundException(); } - await _cipherService.PurgeAsync(orgId); + await _cipherService.PurgeAsync(organizationId!.Value); } } @@ -1222,7 +1414,7 @@ public class CiphersController : Controller [Obsolete("Deprecated Attachments API", false)] [RequestSizeLimit(Constants.FileSize101mb)] [DisableFormValueModelBinding] - public async Task PostAttachment(Guid id) + public async Task PostAttachmentV1(Guid id) { ValidateAttachment(); @@ -1316,7 +1508,6 @@ public class CiphersController : Controller } [HttpDelete("{id}/attachment/{attachmentId}")] - [HttpPost("{id}/attachment/{attachmentId}/delete")] public async Task DeleteAttachment(Guid id, string attachmentId) { var userId = _userService.GetProperUserId(User).Value; @@ -1329,8 +1520,14 @@ public class CiphersController : Controller return await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, false); } + [HttpPost("{id}/attachment/{attachmentId}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteAttachment(Guid id, string attachmentId) + { + return await DeleteAttachment(id, attachmentId); + } + [HttpDelete("{id}/attachment/{attachmentId}/admin")] - [HttpPost("{id}/attachment/{attachmentId}/delete-admin")] public async Task DeleteAttachmentAdmin(Guid id, string attachmentId) { var userId = _userService.GetProperUserId(User).Value; @@ -1344,6 +1541,13 @@ public class CiphersController : Controller return await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, true); } + [HttpPost("{id}/attachment/{attachmentId}/delete-admin")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteAttachmentAdmin(Guid id, string attachmentId) + { + return await DeleteAttachmentAdmin(id, attachmentId); + } + [AllowAnonymous] [HttpPost("attachment/validate/azure")] public async Task AzureValidateFile() @@ -1402,6 +1606,11 @@ public class CiphersController : Controller } } + private async Task GetByIdAsyncAdmin(Guid cipherId) + { + return await _cipherRepository.GetOrganizationDetailsByIdAsync(cipherId); + } + private async Task GetByIdAsync(Guid cipherId, Guid userId) { return await _cipherRepository.GetByIdAsync(cipherId, userId); diff --git a/src/Api/Vault/Controllers/FoldersController.cs b/src/Api/Vault/Controllers/FoldersController.cs index da9e6760c6..195931f60c 100644 --- a/src/Api/Vault/Controllers/FoldersController.cs +++ b/src/Api/Vault/Controllers/FoldersController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Response; using Bit.Core.Exceptions; @@ -42,7 +45,7 @@ public class FoldersController : Controller } [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var userId = _userService.GetProperUserId(User).Value; var folders = await _folderRepository.GetManyByUserIdAsync(userId); @@ -60,7 +63,6 @@ public class FoldersController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(string id, [FromBody] FolderRequestModel model) { var userId = _userService.GetProperUserId(User).Value; @@ -74,8 +76,14 @@ public class FoldersController : Controller return new FolderResponseModel(folder); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostPut(string id, [FromBody] FolderRequestModel model) + { + return await Put(id, model); + } + [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(string id) { var userId = _userService.GetProperUserId(User).Value; @@ -88,6 +96,13 @@ public class FoldersController : Controller await _cipherService.DeleteFolderAsync(folder); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDelete(string id) + { + await Delete(id); + } + [HttpDelete("all")] public async Task DeleteAll() { diff --git a/src/Api/Vault/Controllers/SecurityTaskController.cs b/src/Api/Vault/Controllers/SecurityTaskController.cs index d94c9a9a92..efff200e86 100644 --- a/src/Api/Vault/Controllers/SecurityTaskController.cs +++ b/src/Api/Vault/Controllers/SecurityTaskController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Response; using Bit.Core.Services; @@ -21,6 +24,7 @@ public class SecurityTaskController : Controller private readonly IGetTasksForOrganizationQuery _getTasksForOrganizationQuery; private readonly ICreateManyTasksCommand _createManyTasksCommand; private readonly ICreateManyTaskNotificationsCommand _createManyTaskNotificationsCommand; + private readonly IGetTaskMetricsForOrganizationQuery _getTaskMetricsForOrganizationQuery; public SecurityTaskController( IUserService userService, @@ -28,7 +32,8 @@ public class SecurityTaskController : Controller IMarkTaskAsCompleteCommand markTaskAsCompleteCommand, IGetTasksForOrganizationQuery getTasksForOrganizationQuery, ICreateManyTasksCommand createManyTasksCommand, - ICreateManyTaskNotificationsCommand createManyTaskNotificationsCommand) + ICreateManyTaskNotificationsCommand createManyTaskNotificationsCommand, + IGetTaskMetricsForOrganizationQuery getTaskMetricsForOrganizationQuery) { _userService = userService; _getTaskDetailsForUserQuery = getTaskDetailsForUserQuery; @@ -36,6 +41,7 @@ public class SecurityTaskController : Controller _getTasksForOrganizationQuery = getTasksForOrganizationQuery; _createManyTasksCommand = createManyTasksCommand; _createManyTaskNotificationsCommand = createManyTaskNotificationsCommand; + _getTaskMetricsForOrganizationQuery = getTaskMetricsForOrganizationQuery; } /// @@ -77,6 +83,18 @@ public class SecurityTaskController : Controller return new ListResponseModel(response); } + /// + /// Retrieves security task metrics for an organization. + /// + /// The organization Id + [HttpGet("{organizationId:guid}/metrics")] + public async Task GetTaskMetricsForOrganization([FromRoute] Guid organizationId) + { + var metrics = await _getTaskMetricsForOrganizationQuery.GetTaskMetrics(organizationId); + + return new SecurityTaskMetricsResponseModel(metrics.CompletedTasks, metrics.TotalTasks); + } + /// /// Bulk create security tasks for an organization. /// diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index 568c05d651..54f1b9e70b 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Vault.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Vault.Models.Response; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; diff --git a/src/Api/Vault/Models/CipherAttachmentModel.cs b/src/Api/Vault/Models/CipherAttachmentModel.cs index 1eadfc8ef5..381f66d37d 100644 --- a/src/Api/Vault/Models/CipherAttachmentModel.cs +++ b/src/Api/Vault/Models/CipherAttachmentModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; namespace Bit.Api.Vault.Models; diff --git a/src/Api/Vault/Models/CipherCardModel.cs b/src/Api/Vault/Models/CipherCardModel.cs index 5389de321e..e89dd51330 100644 --- a/src/Api/Vault/Models/CipherCardModel.cs +++ b/src/Api/Vault/Models/CipherCardModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/CipherFido2CredentialModel.cs b/src/Api/Vault/Models/CipherFido2CredentialModel.cs index 09d66a22e5..0133173171 100644 --- a/src/Api/Vault/Models/CipherFido2CredentialModel.cs +++ b/src/Api/Vault/Models/CipherFido2CredentialModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/CipherFieldModel.cs b/src/Api/Vault/Models/CipherFieldModel.cs index d51a766f7a..93abf9f647 100644 --- a/src/Api/Vault/Models/CipherFieldModel.cs +++ b/src/Api/Vault/Models/CipherFieldModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Utilities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/CipherIdentityModel.cs b/src/Api/Vault/Models/CipherIdentityModel.cs index ea32bab93d..6f70a3cc49 100644 --- a/src/Api/Vault/Models/CipherIdentityModel.cs +++ b/src/Api/Vault/Models/CipherIdentityModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/CipherLoginModel.cs b/src/Api/Vault/Models/CipherLoginModel.cs index 9580ebfed4..fc0aad14f8 100644 --- a/src/Api/Vault/Models/CipherLoginModel.cs +++ b/src/Api/Vault/Models/CipherLoginModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/CipherPasswordHistoryModel.cs b/src/Api/Vault/Models/CipherPasswordHistoryModel.cs index 6c70acb049..f9e9eff186 100644 --- a/src/Api/Vault/Models/CipherPasswordHistoryModel.cs +++ b/src/Api/Vault/Models/CipherPasswordHistoryModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/CipherSSHKeyModel.cs b/src/Api/Vault/Models/CipherSSHKeyModel.cs index 47853aa36e..850ffb656c 100644 --- a/src/Api/Vault/Models/CipherSSHKeyModel.cs +++ b/src/Api/Vault/Models/CipherSSHKeyModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; namespace Bit.Api.Vault.Models; diff --git a/src/Api/Vault/Models/Request/AttachmentRequestModel.cs b/src/Api/Vault/Models/Request/AttachmentRequestModel.cs index e66cd56f29..96c66c6044 100644 --- a/src/Api/Vault/Models/Request/AttachmentRequestModel.cs +++ b/src/Api/Vault/Models/Request/AttachmentRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.Vault.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.Vault.Models.Request; public class AttachmentRequestModel { diff --git a/src/Api/Vault/Models/Request/BulkCreateSecurityTasksRequestModel.cs b/src/Api/Vault/Models/Request/BulkCreateSecurityTasksRequestModel.cs index 6c8c7e03b3..d269840298 100644 --- a/src/Api/Vault/Models/Request/BulkCreateSecurityTasksRequestModel.cs +++ b/src/Api/Vault/Models/Request/BulkCreateSecurityTasksRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Vault.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Vault.Models.Api; namespace Bit.Api.Vault.Models.Request; diff --git a/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs b/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs index 54d67995d2..59308dd496 100644 --- a/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.Vault.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.Vault.Models.Request; public class CipherBulkUpdateCollectionsRequestModel { diff --git a/src/Api/Vault/Models/Request/CipherPartialRequestModel.cs b/src/Api/Vault/Models/Request/CipherPartialRequestModel.cs index 6232f4ecf6..02977ca1fe 100644 --- a/src/Api/Vault/Models/Request/CipherPartialRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherPartialRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Vault.Models.Request; diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 229d27e484..b0589a62f9 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -1,11 +1,12 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; -using NS = Newtonsoft.Json; -using NSL = Newtonsoft.Json.Linq; namespace Bit.Api.Vault.Models.Request; @@ -37,12 +38,28 @@ public class CipherRequestModel // TODO: Rename to Attachments whenever the above is finally removed. public Dictionary Attachments2 { get; set; } + [Obsolete("Use Data instead.")] public CipherLoginModel Login { get; set; } + + [Obsolete("Use Data instead.")] public CipherCardModel Card { get; set; } + + [Obsolete("Use Data instead.")] public CipherIdentityModel Identity { get; set; } + + [Obsolete("Use Data instead.")] public CipherSecureNoteModel SecureNote { get; set; } + + [Obsolete("Use Data instead.")] public CipherSSHKeyModel SSHKey { get; set; } + + /// + /// JSON string containing cipher-specific data + /// + [StringLength(500000)] + public string Data { get; set; } public DateTime? LastKnownRevisionDate { get; set; } = null; + public DateTime? ArchivedDate { get; set; } public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true) { @@ -69,33 +86,47 @@ public class CipherRequestModel public Cipher ToCipher(Cipher existingCipher) { - switch (existingCipher.Type) + // If Data field is provided, use it directly + if (!string.IsNullOrWhiteSpace(Data)) { - case CipherType.Login: - var loginObj = NSL.JObject.FromObject(ToCipherLoginData(), - new NS.JsonSerializer { NullValueHandling = NS.NullValueHandling.Ignore }); - // TODO: Switch to JsonNode in .NET 6 https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-use-dom-utf8jsonreader-utf8jsonwriter?pivots=dotnet-6-0 - loginObj[nameof(CipherLoginData.Uri)]?.Parent?.Remove(); - existingCipher.Data = loginObj.ToString(NS.Formatting.None); - break; - case CipherType.Card: - existingCipher.Data = JsonSerializer.Serialize(ToCipherCardData(), JsonHelpers.IgnoreWritingNull); - break; - case CipherType.Identity: - existingCipher.Data = JsonSerializer.Serialize(ToCipherIdentityData(), JsonHelpers.IgnoreWritingNull); - break; - case CipherType.SecureNote: - existingCipher.Data = JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull); - break; - case CipherType.SSHKey: - existingCipher.Data = JsonSerializer.Serialize(ToCipherSSHKeyData(), JsonHelpers.IgnoreWritingNull); - break; - default: - throw new ArgumentException("Unsupported type: " + nameof(Type) + "."); + existingCipher.Data = Data; + } + else + { + // Fallback to structured fields + switch (existingCipher.Type) + { + case CipherType.Login: + var loginData = ToCipherLoginData(); + var loginJson = JsonSerializer.Serialize(loginData, JsonHelpers.IgnoreWritingNull); + var loginObj = JsonDocument.Parse(loginJson); + var loginDict = JsonSerializer.Deserialize>(loginJson); + loginDict?.Remove(nameof(CipherLoginData.Uri)); + + existingCipher.Data = JsonSerializer.Serialize(loginDict, JsonHelpers.IgnoreWritingNull); + break; + case CipherType.Card: + existingCipher.Data = JsonSerializer.Serialize(ToCipherCardData(), JsonHelpers.IgnoreWritingNull); + break; + case CipherType.Identity: + existingCipher.Data = + JsonSerializer.Serialize(ToCipherIdentityData(), JsonHelpers.IgnoreWritingNull); + break; + case CipherType.SecureNote: + existingCipher.Data = + JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull); + break; + case CipherType.SSHKey: + existingCipher.Data = JsonSerializer.Serialize(ToCipherSSHKeyData(), JsonHelpers.IgnoreWritingNull); + break; + default: + throw new ArgumentException("Unsupported type: " + nameof(Type) + "."); + } } existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; + existingCipher.ArchivedDate = ArchivedDate; var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; @@ -313,6 +344,12 @@ public class CipherCollectionsRequestModel public IEnumerable CollectionIds { get; set; } } +public class CipherBulkArchiveRequestModel +{ + [Required] + public IEnumerable Ids { get; set; } +} + public class CipherBulkDeleteRequestModel { [Required] @@ -320,6 +357,12 @@ public class CipherBulkDeleteRequestModel public string OrganizationId { get; set; } } +public class CipherBulkUnarchiveRequestModel +{ + [Required] + public IEnumerable Ids { get; set; } +} + public class CipherBulkRestoreRequestModel { [Required] diff --git a/src/Api/Vault/Models/Request/FolderRequestModel.cs b/src/Api/Vault/Models/Request/FolderRequestModel.cs index db9b65099f..27f34474be 100644 --- a/src/Api/Vault/Models/Request/FolderRequestModel.cs +++ b/src/Api/Vault/Models/Request/FolderRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; diff --git a/src/Api/Vault/Models/Response/AttachmentResponseModel.cs b/src/Api/Vault/Models/Response/AttachmentResponseModel.cs index f3c0261e98..4edebb539e 100644 --- a/src/Api/Vault/Models/Response/AttachmentResponseModel.cs +++ b/src/Api/Vault/Models/Response/AttachmentResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; diff --git a/src/Api/Vault/Models/Response/AttachmentUploadDataResponseModel.cs b/src/Api/Vault/Models/Response/AttachmentUploadDataResponseModel.cs index 9eff417769..bb735ace4b 100644 --- a/src/Api/Vault/Models/Response/AttachmentUploadDataResponseModel.cs +++ b/src/Api/Vault/Models/Response/AttachmentUploadDataResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.Models.Api; namespace Bit.Api.Vault.Models.Response; diff --git a/src/Api/Vault/Models/Response/CipherPermissionsResponseModel.cs b/src/Api/Vault/Models/Response/CipherPermissionsResponseModel.cs index 4f2f7e86b2..b3082fc689 100644 --- a/src/Api/Vault/Models/Response/CipherPermissionsResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherPermissionsResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; using Bit.Core.Vault.Authorization.Permissions; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index 240783837e..dfacc1a551 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Entities; using Bit.Core.Models.Api; using Bit.Core.Models.Data.Organizations; @@ -21,6 +24,7 @@ public class CipherMiniResponseModel : ResponseModel Id = cipher.Id; Type = cipher.Type; + Data = cipher.Data; CipherData cipherData; switch (cipher.Type) @@ -28,30 +32,25 @@ public class CipherMiniResponseModel : ResponseModel case CipherType.Login: var loginData = JsonSerializer.Deserialize(cipher.Data); cipherData = loginData; - Data = loginData; Login = new CipherLoginModel(loginData); break; case CipherType.SecureNote: var secureNoteData = JsonSerializer.Deserialize(cipher.Data); - Data = secureNoteData; cipherData = secureNoteData; SecureNote = new CipherSecureNoteModel(secureNoteData); break; case CipherType.Card: var cardData = JsonSerializer.Deserialize(cipher.Data); - Data = cardData; cipherData = cardData; Card = new CipherCardModel(cardData); break; case CipherType.Identity: var identityData = JsonSerializer.Deserialize(cipher.Data); - Data = identityData; cipherData = identityData; Identity = new CipherIdentityModel(identityData); break; case CipherType.SSHKey: var sshKeyData = JsonSerializer.Deserialize(cipher.Data); - Data = sshKeyData; cipherData = sshKeyData; SSHKey = new CipherSSHKeyModel(sshKeyData); break; @@ -71,20 +70,39 @@ public class CipherMiniResponseModel : ResponseModel DeletedDate = cipher.DeletedDate; Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None); Key = cipher.Key; + ArchivedDate = cipher.ArchivedDate; } public Guid Id { get; set; } public Guid? OrganizationId { get; set; } public CipherType Type { get; set; } - public dynamic Data { get; set; } + public string Data { get; set; } + + [Obsolete("Use Data instead.")] public string Name { get; set; } + + [Obsolete("Use Data instead.")] public string Notes { get; set; } + + [Obsolete("Use Data instead.")] public CipherLoginModel Login { get; set; } + + [Obsolete("Use Data instead.")] public CipherCardModel Card { get; set; } + + [Obsolete("Use Data instead.")] public CipherIdentityModel Identity { get; set; } + + [Obsolete("Use Data instead.")] public CipherSecureNoteModel SecureNote { get; set; } + + [Obsolete("Use Data instead.")] public CipherSSHKeyModel SSHKey { get; set; } + + [Obsolete("Use Data instead.")] public IEnumerable Fields { get; set; } + + [Obsolete("Use Data instead.")] public IEnumerable PasswordHistory { get; set; } public IEnumerable Attachments { get; set; } public bool OrganizationUseTotp { get; set; } @@ -93,6 +111,7 @@ public class CipherMiniResponseModel : ResponseModel public DateTime? DeletedDate { get; set; } public CipherRepromptType Reprompt { get; set; } public string Key { get; set; } + public DateTime? ArchivedDate { get; set; } } public class CipherResponseModel : CipherMiniResponseModel diff --git a/src/Api/Vault/Models/Response/FolderResponseModel.cs b/src/Api/Vault/Models/Response/FolderResponseModel.cs index 72ba08cb3b..21c25b19fe 100644 --- a/src/Api/Vault/Models/Response/FolderResponseModel.cs +++ b/src/Api/Vault/Models/Response/FolderResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.Vault.Entities; namespace Bit.Api.Vault.Models.Response; diff --git a/src/Api/Vault/Models/Response/SecurityTaskMetricsResponseModel.cs b/src/Api/Vault/Models/Response/SecurityTaskMetricsResponseModel.cs new file mode 100644 index 0000000000..502e90ddea --- /dev/null +++ b/src/Api/Vault/Models/Response/SecurityTaskMetricsResponseModel.cs @@ -0,0 +1,21 @@ +namespace Bit.Api.Vault.Models.Response; + +public class SecurityTaskMetricsResponseModel +{ + + public SecurityTaskMetricsResponseModel(int completedTasks, int totalTasks) + { + CompletedTasks = completedTasks; + TotalTasks = totalTasks; + } + + /// + /// Number of tasks that have been completed in the organization. + /// + public int CompletedTasks { get; set; } + + /// + /// Total number of tasks in the organization, regardless of their status. + /// + public int TotalTasks { get; set; } +} diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index b9da786567..e19defce51 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -1,9 +1,13 @@ -using Bit.Api.AdminConsole.Models.Response.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; using Bit.Api.Tools.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Response; using Bit.Core.Models.Api; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; @@ -15,7 +19,7 @@ using Bit.Core.Vault.Models.Data; namespace Bit.Api.Vault.Models.Response; -public class SyncResponseModel : ResponseModel +public class SyncResponseModel() : ResponseModel("sync") { public SyncResponseModel( GlobalSettings globalSettings, @@ -34,7 +38,7 @@ public class SyncResponseModel : ResponseModel bool excludeDomains, IEnumerable policies, IEnumerable sends) - : base("sync") + : this() { Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser); @@ -51,6 +55,23 @@ public class SyncResponseModel : ResponseModel Domains = excludeDomains ? null : new DomainsResponseModel(user, false); Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List(); Sends = sends.Select(s => new SendResponseModel(s, globalSettings)); + UserDecryption = new UserDecryptionResponseModel + { + MasterPasswordUnlock = user.HasMasterPassword() + ? new MasterPasswordUnlockResponseModel + { + Kdf = new MasterPasswordUnlockKdfResponseModel + { + KdfType = user.Kdf, + Iterations = user.KdfIterations, + Memory = user.KdfMemory, + Parallelism = user.KdfParallelism + }, + MasterKeyEncryptedUserKey = user.Key!, + Salt = user.Email.ToLowerInvariant() + } + : null + }; } public ProfileResponseModel Profile { get; set; } @@ -60,4 +81,5 @@ public class SyncResponseModel : ResponseModel public DomainsResponseModel Domains { get; set; } public IEnumerable Policies { get; set; } public IEnumerable Sends { get; set; } + public UserDecryptionResponseModel UserDecryption { get; set; } } diff --git a/src/Api/entrypoint.sh b/src/Api/entrypoint.sh index d89a4648ec..c4f31f1e5e 100644 --- a/src/Api/entrypoint.sh +++ b/src/Api/entrypoint.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # Setup @@ -37,7 +37,7 @@ then mkdir -p /etc/bitwarden/ca-certificates chown -R $USERNAME:$GROUPNAME /etc/bitwarden - if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos fi @@ -46,7 +46,7 @@ else gosu_cmd="" fi -if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then +if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 116efdb68c..e2b7447eb7 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -6,11 +6,13 @@ + - + + diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index ffe73808d4..3dc3e3e808 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -1,4 +1,7 @@ -namespace Bit.Billing; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Billing; public class BillingSettings { @@ -31,11 +34,25 @@ public class BillingSettings public virtual string Region { get; set; } public virtual string UserFieldName { get; set; } public virtual string OrgFieldName { get; set; } + + public virtual bool RemoveNewlinesInReplies { get; set; } = false; + public virtual string AutoReplyGreeting { get; set; } = string.Empty; + public virtual string AutoReplySalutation { get; set; } = string.Empty; } public class OnyxSettings { public virtual string ApiKey { get; set; } public virtual string BaseUrl { get; set; } + public virtual string Path { get; set; } + public virtual int PersonaId { get; set; } + public virtual bool UseAnswerWithCitationModels { get; set; } = true; + + public virtual SearchSettings SearchSettings { get; set; } = new SearchSettings(); + } + public class SearchSettings + { + public virtual string RunSearch { get; set; } = "auto"; // "always", "never", "auto" + public virtual bool RealTime { get; set; } = true; } } diff --git a/src/Billing/Constants/HandledStripeWebhook.cs b/src/Billing/Constants/HandledStripeWebhook.cs index cbcc2065c3..e9e0c5a16b 100644 --- a/src/Billing/Constants/HandledStripeWebhook.cs +++ b/src/Billing/Constants/HandledStripeWebhook.cs @@ -13,4 +13,5 @@ public static class HandledStripeWebhook public const string PaymentMethodAttached = "payment_method.attached"; public const string CustomerUpdated = "customer.updated"; public const string InvoiceFinalized = "invoice.finalized"; + public const string SetupIntentSucceeded = "setup_intent.succeeded"; } diff --git a/src/Billing/Controllers/AppleController.cs b/src/Billing/Controllers/AppleController.cs index 5c231de8ed..c08f1cfa61 100644 --- a/src/Billing/Controllers/AppleController.cs +++ b/src/Billing/Controllers/AppleController.cs @@ -1,4 +1,7 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text; using System.Text.Json; using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; diff --git a/src/Billing/Controllers/BitPayController.cs b/src/Billing/Controllers/BitPayController.cs index a8d1742fcb..111ffabc2b 100644 --- a/src/Billing/Controllers/BitPayController.cs +++ b/src/Billing/Controllers/BitPayController.cs @@ -1,4 +1,7 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using Bit.Billing.Constants; using Bit.Billing.Models; using Bit.Core.AdminConsole.Repositories; diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs index 1fb0fb7ac7..38ed05cfdf 100644 --- a/src/Billing/Controllers/FreshdeskController.cs +++ b/src/Billing/Controllers/FreshdeskController.cs @@ -8,6 +8,7 @@ using Bit.Billing.Models; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; +using Markdig; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -31,7 +32,7 @@ public class FreshdeskController : Controller GlobalSettings globalSettings, IHttpClientFactory httpClientFactory) { - _billingSettings = billingSettings?.Value; + _billingSettings = billingSettings?.Value ?? throw new ArgumentNullException(nameof(billingSettings)); _userRepository = userRepository; _organizationRepository = organizationRepository; _logger = logger; @@ -97,7 +98,8 @@ public class FreshdeskController : Controller customFields[_billingSettings.FreshDesk.OrgFieldName] += $"\n{orgNote}"; } - var planName = GetAttribute(org.PlanType).Name.Split(" ").FirstOrDefault(); + var displayAttribute = GetAttribute(org.PlanType); + var planName = displayAttribute?.Name?.Split(" ").FirstOrDefault(); if (!string.IsNullOrWhiteSpace(planName)) { tags.Add(string.Format("Org: {0}", planName)); @@ -141,7 +143,7 @@ public class FreshdeskController : Controller [HttpPost("webhook-onyx-ai")] public async Task PostWebhookOnyxAi([FromQuery, Required] string key, - [FromBody, Required] FreshdeskWebhookModel model) + [FromBody, Required] FreshdeskOnyxAiWebhookModel model) { // ensure that the key is from Freshdesk if (!IsValidRequestFromFreshdesk(key)) @@ -149,45 +151,68 @@ public class FreshdeskController : Controller return new BadRequestResult(); } - // get ticket info from Freshdesk - var getTicketRequest = new HttpRequestMessage(HttpMethod.Get, - string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}", model.TicketId)); - var getTicketResponse = await CallFreshdeskApiAsync(getTicketRequest); - - // check if we have a valid response from freshdesk - if (getTicketResponse.StatusCode != System.Net.HttpStatusCode.OK) + // if there is no description, then we don't send anything to onyx + if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim())) { - _logger.LogError("Error getting ticket info from Freshdesk. Ticket Id: {0}. Status code: {1}", - model.TicketId, getTicketResponse.StatusCode); - return BadRequest("Failed to retrieve ticket info from Freshdesk"); + return Ok(); } - // extract info from the response - var ticketInfo = await ExtractTicketInfoFromResponse(getTicketResponse); - if (ticketInfo == null) - { - return BadRequest("Failed to extract ticket info from Freshdesk response"); - } - - // create the onyx `answer-with-citation` request - var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(ticketInfo.DescriptionText); - var onyxRequest = new HttpRequestMessage(HttpMethod.Post, - string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl)) - { - Content = JsonContent.Create(onyxRequestModel, mediaType: new MediaTypeHeaderValue("application/json")), - }; - var (_, onyxJsonResponse) = await CallOnyxApi(onyxRequest); + // Get response from Onyx AI + var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model); // the CallOnyxApi will return a null if we have an error response - if (onyxJsonResponse?.Answer == null || !string.IsNullOrEmpty(onyxJsonResponse?.ErrorMsg)) + if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg)) { - return BadRequest( - string.Format("Failed to get a valid response from Onyx API. Response: {0}", - JsonSerializer.Serialize(onyxJsonResponse ?? new OnyxAnswerWithCitationResponseModel()))); + _logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ", + JsonSerializer.Serialize(model), + JsonSerializer.Serialize(onyxRequest), + JsonSerializer.Serialize(onyxResponse)); + + return Ok(); // return ok so we don't retry } // add the answer as a note to the ticket - await AddAnswerNoteToTicketAsync(onyxJsonResponse.Answer, model.TicketId); + await AddAnswerNoteToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId); + + return Ok(); + } + + [HttpPost("webhook-onyx-ai-reply")] + public async Task PostWebhookOnyxAiReply([FromQuery, Required] string key, + [FromBody, Required] FreshdeskOnyxAiWebhookModel model) + { + // NOTE: + // at this time, this endpoint is a duplicate of `webhook-onyx-ai` + // eventually, we will merge both endpoints into one webhook for Freshdesk + + // ensure that the key is from Freshdesk + if (!IsValidRequestFromFreshdesk(key) || !ModelState.IsValid) + { + return new BadRequestResult(); + } + + // if there is no description, then we don't send anything to onyx + if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim())) + { + return Ok(); + } + + // create the onyx `answer-with-citation` request + var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model); + + // the CallOnyxApi will return a null if we have an error response + if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg)) + { + _logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ", + JsonSerializer.Serialize(model), + JsonSerializer.Serialize(onyxRequest), + JsonSerializer.Serialize(onyxResponse)); + + return Ok(); // return ok so we don't retry + } + + // add the reply to the ticket + await AddReplyToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId); return Ok(); } @@ -246,27 +271,51 @@ public class FreshdeskController : Controller } } - private async Task ExtractTicketInfoFromResponse(HttpResponseMessage getTicketResponse) + private async Task AddReplyToTicketAsync(string note, string ticketId) { - var responseString = string.Empty; + // if there is no content, then we don't need to add a note + if (string.IsNullOrWhiteSpace(note)) + { + return; + } + + // convert note from markdown to html + var htmlNote = note; try { - responseString = await getTicketResponse.Content.ReadAsStringAsync(); - var ticketInfo = JsonSerializer.Deserialize(responseString, - options: new System.Text.Json.JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - }); - - return ticketInfo; + var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); + htmlNote = Markdig.Markdown.ToHtml(note, pipeline); } - catch (System.Exception ex) + catch (Exception ex) { - _logger.LogError("Error deserializing ticket info from Freshdesk response. Response: {0}. Exception {1}", - responseString, ex.ToString()); + _logger.LogError(ex, "Error converting markdown to HTML for Freshdesk reply. Ticket Id: {0}. Note: {1}", + ticketId, note); + htmlNote = note; // fallback to the original note } - return null; + // clear out any new lines that Freshdesk doesn't like + if (_billingSettings.FreshDesk.RemoveNewlinesInReplies) + { + htmlNote = htmlNote.Replace(Environment.NewLine, string.Empty); + } + + var replyBody = new FreshdeskReplyRequestModel + { + Body = $"{_billingSettings.FreshDesk.AutoReplyGreeting}{htmlNote}{_billingSettings.FreshDesk.AutoReplySalutation}", + }; + + var replyRequest = new HttpRequestMessage(HttpMethod.Post, + string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/reply", ticketId)) + { + Content = JsonContent.Create(replyBody), + }; + + var addReplyResponse = await CallFreshdeskApiAsync(replyRequest); + if (addReplyResponse.StatusCode != System.Net.HttpStatusCode.Created) + { + _logger.LogError("Error adding reply to Freshdesk ticket. Ticket Id: {0}. Status: {1}", + ticketId, addReplyResponse.ToString()); + } } private async Task CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0) @@ -293,7 +342,32 @@ public class FreshdeskController : Controller return await CallFreshdeskApiAsync(request, retriedCount++); } - private async Task<(HttpResponseMessage, T)> CallOnyxApi(HttpRequestMessage request) + async Task<(OnyxRequestModel onyxRequest, OnyxResponseModel onyxResponse)> GetAnswerFromOnyx(FreshdeskOnyxAiWebhookModel model) + { + // TODO: remove the use of the deprecated answer-with-citation models after we are sure + if (_billingSettings.Onyx.UseAnswerWithCitationModels) + { + var onyxRequest = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx); + var onyxAnswerWithCitationRequest = new HttpRequestMessage(HttpMethod.Post, + string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl)) + { + Content = JsonContent.Create(onyxRequest, mediaType: new MediaTypeHeaderValue("application/json")), + }; + var onyxResponse = await CallOnyxApi(onyxAnswerWithCitationRequest); + return (onyxRequest, onyxResponse); + } + + var request = new OnyxSendMessageSimpleApiRequestModel(model.TicketDescriptionText, _billingSettings.Onyx); + var onyxSimpleRequest = new HttpRequestMessage(HttpMethod.Post, + string.Format("{0}{1}", _billingSettings.Onyx.BaseUrl, _billingSettings.Onyx.Path)) + { + Content = JsonContent.Create(request, mediaType: new MediaTypeHeaderValue("application/json")), + }; + var onyxSimpleResponse = await CallOnyxApi(onyxSimpleRequest); + return (request, onyxSimpleResponse); + } + + private async Task CallOnyxApi(HttpRequestMessage request) where T : class, new() { var httpClient = _httpClientFactory.CreateClient("OnyxApi"); var response = await httpClient.SendAsync(request); @@ -302,7 +376,7 @@ public class FreshdeskController : Controller { _logger.LogError("Error calling Onyx AI API. Status code: {0}. Response {1}", response.StatusCode, JsonSerializer.Serialize(response)); - return (null, default); + return new T(); } var responseStr = await response.Content.ReadAsStringAsync(); var responseJson = JsonSerializer.Deserialize(responseStr, options: new JsonSerializerOptions @@ -310,11 +384,12 @@ public class FreshdeskController : Controller PropertyNameCaseInsensitive = true, }); - return (response, responseJson); + return responseJson ?? new T(); } - private TAttribute GetAttribute(Enum enumValue) where TAttribute : Attribute + private TAttribute? GetAttribute(Enum enumValue) where TAttribute : Attribute { - return enumValue.GetType().GetMember(enumValue.ToString()).First().GetCustomAttribute(); + var memberInfo = enumValue.GetType().GetMember(enumValue.ToString()).FirstOrDefault(); + return memberInfo != null ? memberInfo.GetCustomAttribute() : null; } } diff --git a/src/Billing/Controllers/FreshsalesController.cs b/src/Billing/Controllers/FreshsalesController.cs index 0182011d7a..be5a9ddb16 100644 --- a/src/Billing/Controllers/FreshsalesController.cs +++ b/src/Billing/Controllers/FreshsalesController.cs @@ -1,4 +1,7 @@ -using System.Net.Http.Headers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net.Http.Headers; using System.Text.Json.Serialization; using Bit.Core.Billing.Enums; using Bit.Core.Repositories; diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index 36987c6e44..8039680fd5 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -1,4 +1,7 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text; using Bit.Billing.Models; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Services; diff --git a/src/Billing/Controllers/RecoveryController.cs b/src/Billing/Controllers/RecoveryController.cs index bada1e826d..3f3dc4e650 100644 --- a/src/Billing/Controllers/RecoveryController.cs +++ b/src/Billing/Controllers/RecoveryController.cs @@ -1,4 +1,7 @@ -using Bit.Billing.Models.Recovery; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Models.Recovery; using Bit.Billing.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Http.HttpResults; diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 5ea2733a18..b60e0c56e4 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -1,4 +1,7 @@ -using Bit.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Models; using Bit.Billing.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; diff --git a/src/Billing/Dockerfile b/src/Billing/Dockerfile index 5eb4e9c0e0..1e182dedff 100644 --- a/src/Billing/Dockerfile +++ b/src/Billing/Dockerfile @@ -1,7 +1,7 @@ ############################################### # Build stage # ############################################### -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build # Docker buildx supplies the value for this arg ARG TARGETPLATFORM @@ -9,11 +9,11 @@ ARG TARGETPLATFORM # Determine proper runtime value for .NET # We put the value in a file to be read by later layers. RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ - RID=linux-x64 ; \ + RID=linux-musl-x64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ - RID=linux-arm64 ; \ + RID=linux-musl-arm64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ - RID=linux-arm ; \ + RID=linux-musl-arm ; \ fi \ && echo "RID=$RID" > /tmp/rid.txt @@ -37,20 +37,21 @@ RUN . /tmp/rid.txt && dotnet publish \ ############################################### # App stage # ############################################### -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_URLS=http://+:5000 ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache curl \ + icu-libs \ + shadow \ + tzdata \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu # Copy app from the build stage WORKDIR /app diff --git a/src/Billing/Jobs/SubscriptionCancellationJob.cs b/src/Billing/Jobs/SubscriptionCancellationJob.cs index b59bb10eaf..69b7bc876d 100644 --- a/src/Billing/Jobs/SubscriptionCancellationJob.cs +++ b/src/Billing/Jobs/SubscriptionCancellationJob.cs @@ -1,4 +1,7 @@ -using Bit.Billing.Services; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Services; using Bit.Core.Repositories; using Quartz; using Stripe; diff --git a/src/Billing/Models/BitPayEventModel.cs b/src/Billing/Models/BitPayEventModel.cs index e16391317a..008d4942a6 100644 --- a/src/Billing/Models/BitPayEventModel.cs +++ b/src/Billing/Models/BitPayEventModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Billing.Models; public class BitPayEventModel { diff --git a/src/Billing/Models/FreshdeskReplyRequestModel.cs b/src/Billing/Models/FreshdeskReplyRequestModel.cs new file mode 100644 index 0000000000..3927039769 --- /dev/null +++ b/src/Billing/Models/FreshdeskReplyRequestModel.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Bit.Billing.Models; + +public class FreshdeskReplyRequestModel +{ + [JsonPropertyName("body")] + public required string Body { get; set; } +} diff --git a/src/Billing/Models/FreshdeskViewTicketModel.cs b/src/Billing/Models/FreshdeskViewTicketModel.cs deleted file mode 100644 index 2aa6eff94d..0000000000 --- a/src/Billing/Models/FreshdeskViewTicketModel.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace Bit.Billing.Models; - -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -public class FreshdeskViewTicketModel -{ - [JsonPropertyName("spam")] - public bool? Spam { get; set; } - - [JsonPropertyName("priority")] - public int? Priority { get; set; } - - [JsonPropertyName("source")] - public int? Source { get; set; } - - [JsonPropertyName("status")] - public int? Status { get; set; } - - [JsonPropertyName("subject")] - public string Subject { get; set; } - - [JsonPropertyName("support_email")] - public string SupportEmail { get; set; } - - [JsonPropertyName("id")] - public int Id { get; set; } - - [JsonPropertyName("description")] - public string Description { get; set; } - - [JsonPropertyName("description_text")] - public string DescriptionText { get; set; } - - [JsonPropertyName("created_at")] - public DateTime CreatedAt { get; set; } - - [JsonPropertyName("updated_at")] - public DateTime UpdatedAt { get; set; } - - [JsonPropertyName("tags")] - public List Tags { get; set; } -} diff --git a/src/Billing/Models/FreshdeskWebhookModel.cs b/src/Billing/Models/FreshdeskWebhookModel.cs index e9fe8e026a..aac0e9339d 100644 --- a/src/Billing/Models/FreshdeskWebhookModel.cs +++ b/src/Billing/Models/FreshdeskWebhookModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Billing.Models; @@ -13,3 +16,9 @@ public class FreshdeskWebhookModel [JsonPropertyName("ticket_tags")] public string TicketTags { get; set; } } + +public class FreshdeskOnyxAiWebhookModel : FreshdeskWebhookModel +{ + [JsonPropertyName("ticket_description_text")] + public string TicketDescriptionText { get; set; } +} diff --git a/src/Billing/Models/LoginModel.cs b/src/Billing/Models/LoginModel.cs index 5fe04ad454..f758dc8590 100644 --- a/src/Billing/Models/LoginModel.cs +++ b/src/Billing/Models/LoginModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Billing.Models; diff --git a/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs b/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs index e7bd29b2f5..9a753be4bc 100644 --- a/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs +++ b/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs @@ -1,34 +1,58 @@ - -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; +using static Bit.Billing.BillingSettings; namespace Bit.Billing.Models; -public class OnyxAnswerWithCitationRequestModel +public class OnyxRequestModel { - [JsonPropertyName("messages")] - public List Messages { get; set; } - [JsonPropertyName("persona_id")] public int PersonaId { get; set; } = 1; - [JsonPropertyName("prompt_id")] - public int PromptId { get; set; } = 1; - [JsonPropertyName("retrieval_options")] - public RetrievalOptions RetrievalOptions { get; set; } + public RetrievalOptions RetrievalOptions { get; set; } = new RetrievalOptions(); - public OnyxAnswerWithCitationRequestModel(string message) + public OnyxRequestModel(OnyxSettings onyxSettings) + { + PersonaId = onyxSettings.PersonaId; + RetrievalOptions.RunSearch = onyxSettings.SearchSettings.RunSearch; + RetrievalOptions.RealTime = onyxSettings.SearchSettings.RealTime; + } +} + +/// +/// This is used with the onyx endpoint /query/answer-with-citation +/// which has been deprecated. This can be removed once later +/// +public class OnyxAnswerWithCitationRequestModel : OnyxRequestModel +{ + [JsonPropertyName("messages")] + public List Messages { get; set; } = new List(); + + public OnyxAnswerWithCitationRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings) { message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' '); Messages = new List() { new Message() { MessageText = message } }; - RetrievalOptions = new RetrievalOptions(); + } +} + +/// +/// This is used with the onyx endpoint /chat/send-message-simple-api +/// +public class OnyxSendMessageSimpleApiRequestModel : OnyxRequestModel +{ + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + public OnyxSendMessageSimpleApiRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings) + { + Message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' '); } } public class Message { [JsonPropertyName("message")] - public string MessageText { get; set; } + public string MessageText { get; set; } = string.Empty; [JsonPropertyName("sender")] public string Sender { get; set; } = "user"; @@ -41,9 +65,6 @@ public class RetrievalOptions [JsonPropertyName("real_time")] public bool RealTime { get; set; } = true; - - [JsonPropertyName("limit")] - public int? Limit { get; set; } = 3; } public class RetrievalOptionsRunSearch diff --git a/src/Billing/Models/OnyxAnswerWithCitationResponseModel.cs b/src/Billing/Models/OnyxAnswerWithCitationResponseModel.cs deleted file mode 100644 index e85ee9a674..0000000000 --- a/src/Billing/Models/OnyxAnswerWithCitationResponseModel.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Bit.Billing.Models; - -public class OnyxAnswerWithCitationResponseModel -{ - [JsonPropertyName("answer")] - public string Answer { get; set; } - - [JsonPropertyName("rephrase")] - public string Rephrase { get; set; } - - [JsonPropertyName("citations")] - public List Citations { get; set; } - - [JsonPropertyName("llm_selected_doc_indices")] - public List LlmSelectedDocIndices { get; set; } - - [JsonPropertyName("error_msg")] - public string ErrorMsg { get; set; } -} - -public class Citation -{ - [JsonPropertyName("citation_num")] - public int CitationNum { get; set; } - - [JsonPropertyName("document_id")] - public string DocumentId { get; set; } -} diff --git a/src/Billing/Models/OnyxResponseModel.cs b/src/Billing/Models/OnyxResponseModel.cs new file mode 100644 index 0000000000..96fa134c40 --- /dev/null +++ b/src/Billing/Models/OnyxResponseModel.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Bit.Billing.Models; + +public class OnyxResponseModel +{ + [JsonPropertyName("answer")] + public string Answer { get; set; } = string.Empty; + + [JsonPropertyName("answer_citationless")] + public string AnswerCitationless { get; set; } = string.Empty; + + [JsonPropertyName("error_msg")] + public string ErrorMsg { get; set; } = string.Empty; +} diff --git a/src/Billing/Models/PayPalIPNTransactionModel.cs b/src/Billing/Models/PayPalIPNTransactionModel.cs index 6fd0dfa0c4..34db5fdd04 100644 --- a/src/Billing/Models/PayPalIPNTransactionModel.cs +++ b/src/Billing/Models/PayPalIPNTransactionModel.cs @@ -1,4 +1,7 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using System.Runtime.InteropServices; using System.Web; diff --git a/src/Billing/Models/Recovery/EventsRequestBody.cs b/src/Billing/Models/Recovery/EventsRequestBody.cs index a40f8c9655..f3293cb48a 100644 --- a/src/Billing/Models/Recovery/EventsRequestBody.cs +++ b/src/Billing/Models/Recovery/EventsRequestBody.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Billing.Models.Recovery; diff --git a/src/Billing/Models/Recovery/EventsResponseBody.cs b/src/Billing/Models/Recovery/EventsResponseBody.cs index a0c7f087b7..a706734133 100644 --- a/src/Billing/Models/Recovery/EventsResponseBody.cs +++ b/src/Billing/Models/Recovery/EventsResponseBody.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Billing.Models.Recovery; diff --git a/src/Billing/Models/StripeWebhookDeliveryContainer.cs b/src/Billing/Models/StripeWebhookDeliveryContainer.cs index 6588aa7d13..9d566146fb 100644 --- a/src/Billing/Models/StripeWebhookDeliveryContainer.cs +++ b/src/Billing/Models/StripeWebhookDeliveryContainer.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Billing.Models; diff --git a/src/Billing/Services/IPushNotificationAdapter.cs b/src/Billing/Services/IPushNotificationAdapter.cs new file mode 100644 index 0000000000..2f74f35eec --- /dev/null +++ b/src/Billing/Services/IPushNotificationAdapter.cs @@ -0,0 +1,11 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; + +namespace Bit.Billing.Services; + +public interface IPushNotificationAdapter +{ + Task NotifyBankAccountVerifiedAsync(Organization organization); + Task NotifyBankAccountVerifiedAsync(Provider provider); + Task NotifyEnabledChangedAsync(Organization organization); +} diff --git a/src/Billing/Services/IStripeEventService.cs b/src/Billing/Services/IStripeEventService.cs index 6e2239cf98..567d404ba6 100644 --- a/src/Billing/Services/IStripeEventService.cs +++ b/src/Billing/Services/IStripeEventService.cs @@ -10,12 +10,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the charge object from Stripe. + /// Determines whether to retrieve a fresh copy of the charge object from Stripe. /// Optionally provided to expand the fresh charge object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain a charge object. - /// Thrown when is true and Stripe's API returns a null charge object. - Task GetCharge(Event stripeEvent, bool fresh = false, List expand = null); + Task GetCharge(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -23,12 +21,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the customer object from Stripe. + /// Determines whether to retrieve a fresh copy of the customer object from Stripe. /// Optionally provided to expand the fresh customer object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain a customer object. - /// Thrown when is true and Stripe's API returns a null customer object. - Task GetCustomer(Event stripeEvent, bool fresh = false, List expand = null); + Task GetCustomer(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -36,12 +32,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the invoice object from Stripe. + /// Determines whether to retrieve a fresh copy of the invoice object from Stripe. /// Optionally provided to expand the fresh invoice object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain an invoice object. - /// Thrown when is true and Stripe's API returns a null invoice object. - Task GetInvoice(Event stripeEvent, bool fresh = false, List expand = null); + Task GetInvoice(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -49,12 +43,21 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the payment method object from Stripe. + /// Determines whether to retrieve a fresh copy of the payment method object from Stripe. /// Optionally provided to expand the fresh payment method object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain an payment method object. - /// Thrown when is true and Stripe's API returns a null payment method object. - Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List expand = null); + Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List? expand = null); + + /// + /// Extracts the object from the Stripe . When is true, + /// uses the setup intent ID extracted from the event to retrieve the most up-to-update setup intent from Stripe's API + /// and optionally expands it with the provided options. + /// + /// The Stripe webhook event. + /// Determines whether to retrieve a fresh copy of the setup intent object from Stripe. + /// Optionally provided to expand the fresh setup intent object retrieved from Stripe. + /// A Stripe . + Task GetSetupIntent(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -62,12 +65,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the subscription object from Stripe. + /// Determines whether to retrieve a fresh copy of the subscription object from Stripe. /// Optionally provided to expand the fresh subscription object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain an subscription object. - /// Thrown when is true and Stripe's API returns a null subscription object. - Task GetSubscription(Event stripeEvent, bool fresh = false, List expand = null); + Task GetSubscription(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Ensures that the customer associated with the Stripe is in the correct region for this server. diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index e53d901083..280a3aca3c 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -1,4 +1,8 @@ -using Stripe; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Stripe; +using Stripe.TestHelpers; namespace Bit.Billing.Services; @@ -34,6 +38,12 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + Task GetSetupIntent( + string setupIntentId, + SetupIntentGetOptions setupIntentGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task> ListInvoices( InvoiceListOptions options = null, RequestOptions requestOptions = null, @@ -95,4 +105,10 @@ public interface IStripeFacade string subscriptionId, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task GetTestClock( + string testClockId, + TestClockGetOptions testClockGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); } diff --git a/src/Billing/Services/IStripeWebhookHandler.cs b/src/Billing/Services/IStripeWebhookHandler.cs index 59be435489..2619b2f663 100644 --- a/src/Billing/Services/IStripeWebhookHandler.cs +++ b/src/Billing/Services/IStripeWebhookHandler.cs @@ -65,3 +65,5 @@ public interface ICustomerUpdatedHandler : IStripeWebhookHandler; /// Defines the contract for handling Stripe Invoice Finalized events. /// public interface IInvoiceFinalizedHandler : IStripeWebhookHandler; + +public interface ISetupIntentSucceededHandler : IStripeWebhookHandler; diff --git a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs index 97bb29c35d..548a41879c 100644 --- a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs +++ b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs @@ -1,10 +1,11 @@ -using Bit.Billing.Constants; -using Bit.Core; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Constants; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; -using Bit.Core.Services; using Stripe; using Event = Stripe.Event; @@ -16,41 +17,22 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler private readonly IStripeEventService _stripeEventService; private readonly IStripeFacade _stripeFacade; private readonly IStripeEventUtilityService _stripeEventUtilityService; - private readonly IFeatureService _featureService; private readonly IProviderRepository _providerRepository; - public PaymentMethodAttachedHandler( - ILogger logger, + public PaymentMethodAttachedHandler(ILogger logger, IStripeEventService stripeEventService, IStripeFacade stripeFacade, IStripeEventUtilityService stripeEventUtilityService, - IFeatureService featureService, IProviderRepository providerRepository) { _logger = logger; _stripeEventService = stripeEventService; _stripeFacade = stripeFacade; _stripeEventUtilityService = stripeEventUtilityService; - _featureService = featureService; _providerRepository = providerRepository; } public async Task HandleAsync(Event parsedEvent) - { - var updateMSPToChargeAutomatically = - _featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically); - - if (updateMSPToChargeAutomatically) - { - await HandleVNextAsync(parsedEvent); - } - else - { - await HandleVCurrentAsync(parsedEvent); - } - } - - private async Task HandleVNextAsync(Event parsedEvent) { var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent, true, ["customer.subscriptions.data.latest_invoice"]); @@ -133,42 +115,6 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler } } - private async Task HandleVCurrentAsync(Event parsedEvent) - { - var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent); - if (paymentMethod is null) - { - _logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null"); - return; - } - - var subscriptionListOptions = new SubscriptionListOptions - { - Customer = paymentMethod.CustomerId, - Status = StripeSubscriptionStatus.Unpaid, - Expand = ["data.latest_invoice"] - }; - - StripeList unpaidSubscriptions; - try - { - unpaidSubscriptions = await _stripeFacade.ListSubscriptions(subscriptionListOptions); - } - catch (Exception e) - { - _logger.LogError(e, - "Attempted to get unpaid invoices for customer {CustomerId} but encountered an error while calling Stripe", - paymentMethod.CustomerId); - - return; - } - - foreach (var unpaidSubscription in unpaidSubscriptions) - { - await AttemptToPayOpenSubscriptionAsync(unpaidSubscription); - } - } - private async Task AttemptToPayOpenSubscriptionAsync(Subscription unpaidSubscription) { var latestInvoice = unpaidSubscription.LatestInvoice; diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index 4c256e3d85..a10fa4b3d6 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -3,63 +3,38 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; -public class PaymentSucceededHandler : IPaymentSucceededHandler +public class PaymentSucceededHandler( + ILogger logger, + IStripeEventService stripeEventService, + IStripeFacade stripeFacade, + IProviderRepository providerRepository, + IOrganizationRepository organizationRepository, + IStripeEventUtilityService stripeEventUtilityService, + IUserService userService, + IOrganizationEnableCommand organizationEnableCommand, + IPricingClient pricingClient, + IPushNotificationAdapter pushNotificationAdapter) + : IPaymentSucceededHandler { - private readonly ILogger _logger; - private readonly IStripeEventService _stripeEventService; - private readonly IUserService _userService; - private readonly IStripeFacade _stripeFacade; - private readonly IProviderRepository _providerRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly IStripeEventUtilityService _stripeEventUtilityService; - private readonly IPushNotificationService _pushNotificationService; - private readonly IOrganizationEnableCommand _organizationEnableCommand; - private readonly IPricingClient _pricingClient; - - public PaymentSucceededHandler( - ILogger logger, - IStripeEventService stripeEventService, - IStripeFacade stripeFacade, - IProviderRepository providerRepository, - IOrganizationRepository organizationRepository, - IStripeEventUtilityService stripeEventUtilityService, - IUserService userService, - IPushNotificationService pushNotificationService, - IOrganizationEnableCommand organizationEnableCommand, - IPricingClient pricingClient) - { - _logger = logger; - _stripeEventService = stripeEventService; - _stripeFacade = stripeFacade; - _providerRepository = providerRepository; - _organizationRepository = organizationRepository; - _stripeEventUtilityService = stripeEventUtilityService; - _userService = userService; - _pushNotificationService = pushNotificationService; - _organizationEnableCommand = organizationEnableCommand; - _pricingClient = pricingClient; - } - /// /// Handles the event type from Stripe. /// /// public async Task HandleAsync(Event parsedEvent) { - var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); + var invoice = await stripeEventService.GetInvoice(parsedEvent, true); if (!invoice.Paid || invoice.BillingReason != "subscription_create") { return; } - var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); + var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId); if (subscription?.Status != StripeSubscriptionStatus.Active) { return; @@ -70,15 +45,15 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler await Task.Delay(5000); } - var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); + var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); if (providerId.HasValue) { - var provider = await _providerRepository.GetByIdAsync(providerId.Value); + var provider = await providerRepository.GetByIdAsync(providerId.Value); if (provider == null) { - _logger.LogError( + logger.LogError( "Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist", parsedEvent.Id, providerId.Value); @@ -86,9 +61,9 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler return; } - var teamsMonthly = await _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly); + var teamsMonthly = await pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly); - var enterpriseMonthly = await _pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly); + var enterpriseMonthly = await pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly); var teamsMonthlyLineItem = subscription.Items.Data.FirstOrDefault(item => @@ -100,29 +75,30 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null) { - _logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items", + logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items", parsedEvent.Id, provider.Id); } } else if (organizationId.HasValue) { - var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); + var organization = await organizationRepository.GetByIdAsync(organizationId.Value); if (organization == null) { return; } - var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); if (subscription.Items.All(item => plan.PasswordManager.StripePlanId != item.Plan.Id)) { return; } - await _organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); - await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); + await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); + organization = await organizationRepository.GetByIdAsync(organization.Id); + await pushNotificationAdapter.NotifyEnabledChangedAsync(organization!); } else if (userId.HasValue) { @@ -131,7 +107,7 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler return; } - await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); + await userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); } } } diff --git a/src/Billing/Services/Implementations/ProviderEventService.cs b/src/Billing/Services/Implementations/ProviderEventService.cs index 1f6ef741df..12716c5aa2 100644 --- a/src/Billing/Services/Implementations/ProviderEventService.cs +++ b/src/Billing/Services/Implementations/ProviderEventService.cs @@ -1,4 +1,7 @@ -using Bit.Billing.Constants; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Constants; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; diff --git a/src/Billing/Services/Implementations/PushNotificationAdapter.cs b/src/Billing/Services/Implementations/PushNotificationAdapter.cs new file mode 100644 index 0000000000..673ae1415e --- /dev/null +++ b/src/Billing/Services/Implementations/PushNotificationAdapter.cs @@ -0,0 +1,71 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Models; +using Bit.Core.Platform.Push; + +namespace Bit.Billing.Services.Implementations; + +public class PushNotificationAdapter( + IProviderUserRepository providerUserRepository, + IPushNotificationService pushNotificationService) : IPushNotificationAdapter +{ + public Task NotifyBankAccountVerifiedAsync(Organization organization) => + pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.OrganizationBankAccountVerified, + Target = NotificationTarget.Organization, + TargetId = organization.Id, + Payload = new OrganizationBankAccountVerifiedPushNotification + { + OrganizationId = organization.Id + }, + ExcludeCurrentContext = false + }); + + public async Task NotifyBankAccountVerifiedAsync(Provider provider) + { + var providerUsers = await providerUserRepository.GetManyByProviderAsync(provider.Id); + var providerAdmins = providerUsers.Where(providerUser => providerUser is + { + Type: ProviderUserType.ProviderAdmin, + Status: ProviderUserStatusType.Confirmed, + UserId: not null + }).ToList(); + + if (providerAdmins.Count > 0) + { + var tasks = providerAdmins.Select(providerAdmin => pushNotificationService.PushAsync( + new PushNotification + { + Type = PushType.ProviderBankAccountVerified, + Target = NotificationTarget.User, + TargetId = providerAdmin.UserId!.Value, + Payload = new ProviderBankAccountVerifiedPushNotification + { + ProviderId = provider.Id, + AdminId = providerAdmin.UserId!.Value + }, + ExcludeCurrentContext = false + })); + + await Task.WhenAll(tasks); + } + } + + public Task NotifyEnabledChangedAsync(Organization organization) => + pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.SyncOrganizationStatusChanged, + Target = NotificationTarget.Organization, + TargetId = organization.Id, + Payload = new OrganizationStatusPushNotification + { + OrganizationId = organization.Id, + Enabled = organization.Enabled, + }, + ExcludeCurrentContext = false, + }); +} diff --git a/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs new file mode 100644 index 0000000000..bc3fa1bd56 --- /dev/null +++ b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs @@ -0,0 +1,77 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; +using Bit.Core.Repositories; +using Bit.Core.Services; +using OneOf; +using Stripe; +using Event = Stripe.Event; + +namespace Bit.Billing.Services.Implementations; + +public class SetupIntentSucceededHandler( + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IPushNotificationAdapter pushNotificationAdapter, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + IStripeEventService stripeEventService) : ISetupIntentSucceededHandler +{ + public async Task HandleAsync(Event parsedEvent) + { + var setupIntent = await stripeEventService.GetSetupIntent( + parsedEvent, + true, + ["payment_method"]); + + if (setupIntent is not + { + PaymentMethod.UsBankAccount: not null + }) + { + return; + } + + var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id); + if (subscriberId == null) + { + return; + } + + var organization = await organizationRepository.GetByIdAsync(subscriberId.Value); + var provider = await providerRepository.GetByIdAsync(subscriberId.Value); + + OneOf entity = organization != null ? organization : provider!; + await SetPaymentMethodAsync(entity, setupIntent.PaymentMethod); + } + + private async Task SetPaymentMethodAsync( + OneOf subscriber, + PaymentMethod paymentMethod) + { + var customerId = subscriber.Match( + organization => organization.GatewayCustomerId, + provider => provider.GatewayCustomerId); + + if (string.IsNullOrEmpty(customerId)) + { + return; + } + + await stripeAdapter.PaymentMethodAttachAsync(paymentMethod.Id, + new PaymentMethodAttachOptions { Customer = customerId }); + + await stripeAdapter.CustomerUpdateAsync(customerId, new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = paymentMethod.Id + } + }); + + await subscriber.Match( + async organization => await pushNotificationAdapter.NotifyBankAccountVerifiedAsync(organization), + async provider => await pushNotificationAdapter.NotifyBankAccountVerifiedAsync(provider)); + } +} diff --git a/src/Billing/Services/Implementations/StripeEventProcessor.cs b/src/Billing/Services/Implementations/StripeEventProcessor.cs index b0d9cf187d..6db813f70c 100644 --- a/src/Billing/Services/Implementations/StripeEventProcessor.cs +++ b/src/Billing/Services/Implementations/StripeEventProcessor.cs @@ -3,88 +3,64 @@ using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; -public class StripeEventProcessor : IStripeEventProcessor +public class StripeEventProcessor( + ILogger logger, + ISubscriptionDeletedHandler subscriptionDeletedHandler, + ISubscriptionUpdatedHandler subscriptionUpdatedHandler, + IUpcomingInvoiceHandler upcomingInvoiceHandler, + IChargeSucceededHandler chargeSucceededHandler, + IChargeRefundedHandler chargeRefundedHandler, + IPaymentSucceededHandler paymentSucceededHandler, + IPaymentFailedHandler paymentFailedHandler, + IInvoiceCreatedHandler invoiceCreatedHandler, + IPaymentMethodAttachedHandler paymentMethodAttachedHandler, + ICustomerUpdatedHandler customerUpdatedHandler, + IInvoiceFinalizedHandler invoiceFinalizedHandler, + ISetupIntentSucceededHandler setupIntentSucceededHandler) + : IStripeEventProcessor { - private readonly ILogger _logger; - private readonly ISubscriptionDeletedHandler _subscriptionDeletedHandler; - private readonly ISubscriptionUpdatedHandler _subscriptionUpdatedHandler; - private readonly IUpcomingInvoiceHandler _upcomingInvoiceHandler; - private readonly IChargeSucceededHandler _chargeSucceededHandler; - private readonly IChargeRefundedHandler _chargeRefundedHandler; - private readonly IPaymentSucceededHandler _paymentSucceededHandler; - private readonly IPaymentFailedHandler _paymentFailedHandler; - private readonly IInvoiceCreatedHandler _invoiceCreatedHandler; - private readonly IPaymentMethodAttachedHandler _paymentMethodAttachedHandler; - private readonly ICustomerUpdatedHandler _customerUpdatedHandler; - private readonly IInvoiceFinalizedHandler _invoiceFinalizedHandler; - - public StripeEventProcessor( - ILogger logger, - ISubscriptionDeletedHandler subscriptionDeletedHandler, - ISubscriptionUpdatedHandler subscriptionUpdatedHandler, - IUpcomingInvoiceHandler upcomingInvoiceHandler, - IChargeSucceededHandler chargeSucceededHandler, - IChargeRefundedHandler chargeRefundedHandler, - IPaymentSucceededHandler paymentSucceededHandler, - IPaymentFailedHandler paymentFailedHandler, - IInvoiceCreatedHandler invoiceCreatedHandler, - IPaymentMethodAttachedHandler paymentMethodAttachedHandler, - ICustomerUpdatedHandler customerUpdatedHandler, - IInvoiceFinalizedHandler invoiceFinalizedHandler) - { - _logger = logger; - _subscriptionDeletedHandler = subscriptionDeletedHandler; - _subscriptionUpdatedHandler = subscriptionUpdatedHandler; - _upcomingInvoiceHandler = upcomingInvoiceHandler; - _chargeSucceededHandler = chargeSucceededHandler; - _chargeRefundedHandler = chargeRefundedHandler; - _paymentSucceededHandler = paymentSucceededHandler; - _paymentFailedHandler = paymentFailedHandler; - _invoiceCreatedHandler = invoiceCreatedHandler; - _paymentMethodAttachedHandler = paymentMethodAttachedHandler; - _customerUpdatedHandler = customerUpdatedHandler; - _invoiceFinalizedHandler = invoiceFinalizedHandler; - } - public async Task ProcessEventAsync(Event parsedEvent) { switch (parsedEvent.Type) { case HandledStripeWebhook.SubscriptionDeleted: - await _subscriptionDeletedHandler.HandleAsync(parsedEvent); + await subscriptionDeletedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.SubscriptionUpdated: - await _subscriptionUpdatedHandler.HandleAsync(parsedEvent); + await subscriptionUpdatedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.UpcomingInvoice: - await _upcomingInvoiceHandler.HandleAsync(parsedEvent); + await upcomingInvoiceHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.ChargeSucceeded: - await _chargeSucceededHandler.HandleAsync(parsedEvent); + await chargeSucceededHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.ChargeRefunded: - await _chargeRefundedHandler.HandleAsync(parsedEvent); + await chargeRefundedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.PaymentSucceeded: - await _paymentSucceededHandler.HandleAsync(parsedEvent); + await paymentSucceededHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.PaymentFailed: - await _paymentFailedHandler.HandleAsync(parsedEvent); + await paymentFailedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.InvoiceCreated: - await _invoiceCreatedHandler.HandleAsync(parsedEvent); + await invoiceCreatedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.PaymentMethodAttached: - await _paymentMethodAttachedHandler.HandleAsync(parsedEvent); + await paymentMethodAttachedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.CustomerUpdated: - await _customerUpdatedHandler.HandleAsync(parsedEvent); + await customerUpdatedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.InvoiceFinalized: - await _invoiceFinalizedHandler.HandleAsync(parsedEvent); + await invoiceFinalizedHandler.HandleAsync(parsedEvent); + break; + case HandledStripeWebhook.SetupIntentSucceeded: + await setupIntentSucceededHandler.HandleAsync(parsedEvent); break; default: - _logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type); + logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type); break; } } diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs index 8d947e0ccb..03ca8eeb10 100644 --- a/src/Billing/Services/Implementations/StripeEventService.cs +++ b/src/Billing/Services/Implementations/StripeEventService.cs @@ -1,180 +1,122 @@ using Bit.Billing.Constants; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; +using Bit.Core.Repositories; using Bit.Core.Settings; using Stripe; namespace Bit.Billing.Services.Implementations; -public class StripeEventService : IStripeEventService +public class StripeEventService( + GlobalSettings globalSettings, + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + ISetupIntentCache setupIntentCache, + IStripeFacade stripeFacade) + : IStripeEventService { - private readonly GlobalSettings _globalSettings; - private readonly ILogger _logger; - private readonly IStripeFacade _stripeFacade; - - public StripeEventService( - GlobalSettings globalSettings, - ILogger logger, - IStripeFacade stripeFacade) + public async Task GetCharge(Event stripeEvent, bool fresh = false, List? expand = null) { - _globalSettings = globalSettings; - _logger = logger; - _stripeFacade = stripeFacade; - } - - public async Task GetCharge(Event stripeEvent, bool fresh = false, List expand = null) - { - var eventCharge = Extract(stripeEvent); + var charge = Extract(stripeEvent); if (!fresh) { - return eventCharge; + return charge; } - if (string.IsNullOrEmpty(eventCharge.Id)) - { - _logger.LogWarning("Cannot retrieve up-to-date Charge for Event with ID '{eventId}' because no Charge ID was included in the Event.", stripeEvent.Id); - return eventCharge; - } - - var charge = await _stripeFacade.GetCharge(eventCharge.Id, new ChargeGetOptions { Expand = expand }); - - if (charge == null) - { - throw new Exception( - $"Received null Charge from Stripe for ID '{eventCharge.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return charge; + return await stripeFacade.GetCharge(charge.Id, new ChargeGetOptions { Expand = expand }); } - public async Task GetCustomer(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetCustomer(Event stripeEvent, bool fresh = false, List? expand = null) { - var eventCustomer = Extract(stripeEvent); + var customer = Extract(stripeEvent); if (!fresh) { - return eventCustomer; + return customer; } - if (string.IsNullOrEmpty(eventCustomer.Id)) - { - _logger.LogWarning("Cannot retrieve up-to-date Customer for Event with ID '{eventId}' because no Customer ID was included in the Event.", stripeEvent.Id); - return eventCustomer; - } - - var customer = await _stripeFacade.GetCustomer(eventCustomer.Id, new CustomerGetOptions { Expand = expand }); - - if (customer == null) - { - throw new Exception( - $"Received null Customer from Stripe for ID '{eventCustomer.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return customer; + return await stripeFacade.GetCustomer(customer.Id, new CustomerGetOptions { Expand = expand }); } - public async Task GetInvoice(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetInvoice(Event stripeEvent, bool fresh = false, List? expand = null) { - var eventInvoice = Extract(stripeEvent); + var invoice = Extract(stripeEvent); if (!fresh) { - return eventInvoice; + return invoice; } - if (string.IsNullOrEmpty(eventInvoice.Id)) - { - _logger.LogWarning("Cannot retrieve up-to-date Invoice for Event with ID '{eventId}' because no Invoice ID was included in the Event.", stripeEvent.Id); - return eventInvoice; - } - - var invoice = await _stripeFacade.GetInvoice(eventInvoice.Id, new InvoiceGetOptions { Expand = expand }); - - if (invoice == null) - { - throw new Exception( - $"Received null Invoice from Stripe for ID '{eventInvoice.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return invoice; + return await stripeFacade.GetInvoice(invoice.Id, new InvoiceGetOptions { Expand = expand }); } - public async Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetPaymentMethod(Event stripeEvent, bool fresh = false, + List? expand = null) { - var eventPaymentMethod = Extract(stripeEvent); + var paymentMethod = Extract(stripeEvent); if (!fresh) { - return eventPaymentMethod; + return paymentMethod; } - if (string.IsNullOrEmpty(eventPaymentMethod.Id)) - { - _logger.LogWarning("Cannot retrieve up-to-date Payment Method for Event with ID '{eventId}' because no Payment Method ID was included in the Event.", stripeEvent.Id); - return eventPaymentMethod; - } - - var paymentMethod = await _stripeFacade.GetPaymentMethod(eventPaymentMethod.Id, new PaymentMethodGetOptions { Expand = expand }); - - if (paymentMethod == null) - { - throw new Exception( - $"Received null Payment Method from Stripe for ID '{eventPaymentMethod.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return paymentMethod; + return await stripeFacade.GetPaymentMethod(paymentMethod.Id, new PaymentMethodGetOptions { Expand = expand }); } - public async Task GetSubscription(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetSetupIntent(Event stripeEvent, bool fresh = false, List? expand = null) { - var eventSubscription = Extract(stripeEvent); + var setupIntent = Extract(stripeEvent); if (!fresh) { - return eventSubscription; + return setupIntent; } - if (string.IsNullOrEmpty(eventSubscription.Id)) + return await stripeFacade.GetSetupIntent(setupIntent.Id, new SetupIntentGetOptions { Expand = expand }); + } + + public async Task GetSubscription(Event stripeEvent, bool fresh = false, List? expand = null) + { + var subscription = Extract(stripeEvent); + + if (!fresh) { - _logger.LogWarning("Cannot retrieve up-to-date Subscription for Event with ID '{eventId}' because no Subscription ID was included in the Event.", stripeEvent.Id); - return eventSubscription; + return subscription; } - var subscription = await _stripeFacade.GetSubscription(eventSubscription.Id, new SubscriptionGetOptions { Expand = expand }); - - if (subscription == null) - { - throw new Exception( - $"Received null Subscription from Stripe for ID '{eventSubscription.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return subscription; + return await stripeFacade.GetSubscription(subscription.Id, new SubscriptionGetOptions { Expand = expand }); } public async Task ValidateCloudRegion(Event stripeEvent) { - var serverRegion = _globalSettings.BaseServiceUri.CloudRegion; + var serverRegion = globalSettings.BaseServiceUri.CloudRegion; var customerExpansion = new List { "customer" }; var customerMetadata = stripeEvent.Type switch { HandledStripeWebhook.SubscriptionDeleted or HandledStripeWebhook.SubscriptionUpdated => - (await GetSubscription(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + (await GetSubscription(stripeEvent, true, customerExpansion)).Customer?.Metadata, HandledStripeWebhook.ChargeSucceeded or HandledStripeWebhook.ChargeRefunded => - (await GetCharge(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + (await GetCharge(stripeEvent, true, customerExpansion)).Customer?.Metadata, HandledStripeWebhook.UpcomingInvoice => await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent), - HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized => - (await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed + or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized => + (await GetInvoice(stripeEvent, true, customerExpansion)).Customer?.Metadata, HandledStripeWebhook.PaymentMethodAttached => - (await GetPaymentMethod(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + (await GetPaymentMethod(stripeEvent, true, customerExpansion)).Customer?.Metadata, HandledStripeWebhook.CustomerUpdated => - (await GetCustomer(stripeEvent, true))?.Metadata, + (await GetCustomer(stripeEvent, true)).Metadata, + + HandledStripeWebhook.SetupIntentSucceeded => + await GetCustomerMetadataFromSetupIntentSucceededEvent(stripeEvent), _ => null }; @@ -191,51 +133,69 @@ public class StripeEventService : IStripeEventService /* In Stripe, when we receive an invoice.upcoming event, the event does not include an Invoice ID because the invoice hasn't been created yet. Therefore, rather than retrieving the fresh Invoice with a 'customer' expansion, we need to use the Customer ID on the event to retrieve the metadata. */ - async Task> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent) + async Task?> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent) { var invoice = await GetInvoice(localStripeEvent); var customer = !string.IsNullOrEmpty(invoice.CustomerId) - ? await _stripeFacade.GetCustomer(invoice.CustomerId) + ? await stripeFacade.GetCustomer(invoice.CustomerId) : null; return customer?.Metadata; } + + async Task?> GetCustomerMetadataFromSetupIntentSucceededEvent(Event localStripeEvent) + { + var setupIntent = await GetSetupIntent(localStripeEvent); + + var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id); + if (subscriberId == null) + { + return null; + } + + var organization = await organizationRepository.GetByIdAsync(subscriberId.Value); + if (organization is { GatewayCustomerId: not null }) + { + var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId); + return organizationCustomer.Metadata; + } + + var provider = await providerRepository.GetByIdAsync(subscriberId.Value); + if (provider is not { GatewayCustomerId: not null }) + { + return null; + } + + var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId); + return providerCustomer.Metadata; + } } private static T Extract(Event stripeEvent) - { - if (stripeEvent.Data.Object is not T type) - { - throw new Exception($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'"); - } - - return type; - } + => stripeEvent.Data.Object is not T type + ? throw new Exception( + $"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'") + : type; private static string GetCustomerRegion(IDictionary customerMetadata) { - const string defaultRegion = "US"; - - if (customerMetadata is null) - { - return null; - } + const string defaultRegion = Core.Constants.CountryAbbreviations.UnitedStates; if (customerMetadata.TryGetValue("region", out var value)) { return value; } - var miscasedRegionKey = customerMetadata.Keys + var incorrectlyCasedRegionKey = customerMetadata.Keys .FirstOrDefault(key => key.Equals("region", StringComparison.OrdinalIgnoreCase)); - if (miscasedRegionKey is null) + if (incorrectlyCasedRegionKey is null) { return defaultRegion; } - _ = customerMetadata.TryGetValue(miscasedRegionKey, out var regionValue); + _ = customerMetadata.TryGetValue(incorrectlyCasedRegionKey, out var regionValue); return !string.IsNullOrWhiteSpace(regionValue) ? regionValue diff --git a/src/Billing/Services/Implementations/StripeEventUtilityService.cs b/src/Billing/Services/Implementations/StripeEventUtilityService.cs index 48e81dee61..4c96bf977d 100644 --- a/src/Billing/Services/Implementations/StripeEventUtilityService.cs +++ b/src/Billing/Services/Implementations/StripeEventUtilityService.cs @@ -1,4 +1,7 @@ -using Bit.Billing.Constants; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Constants; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 191f84a343..eef7ce009e 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -1,4 +1,9 @@ -using Stripe; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Stripe; +using Stripe.TestHelpers; +using CustomerService = Stripe.CustomerService; namespace Bit.Billing.Services.Implementations; @@ -11,6 +16,8 @@ public class StripeFacade : IStripeFacade private readonly PaymentMethodService _paymentMethodService = new(); private readonly SubscriptionService _subscriptionService = new(); private readonly DiscountService _discountService = new(); + private readonly SetupIntentService _setupIntentService = new(); + private readonly TestClockService _testClockService = new(); public async Task GetCharge( string chargeId, @@ -47,6 +54,13 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _invoiceService.GetAsync(invoiceId, invoiceGetOptions, requestOptions, cancellationToken); + public async Task GetSetupIntent( + string setupIntentId, + SetupIntentGetOptions setupIntentGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _setupIntentService.GetAsync(setupIntentId, setupIntentGetOptions, requestOptions, cancellationToken); + public async Task> ListInvoices( InvoiceListOptions options = null, RequestOptions requestOptions = null, @@ -116,4 +130,11 @@ public class StripeFacade : IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default) => await _discountService.DeleteSubscriptionDiscountAsync(subscriptionId, requestOptions, cancellationToken); + + public Task GetTestClock( + string testClockId, + TestClockGetOptions testClockGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + _testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken); } diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index fe5021c827..10630f78f4 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,13 +1,17 @@ -using Bit.Billing.Constants; +using System.Globalization; +using Bit.Billing.Constants; using Bit.Billing.Jobs; +using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; -using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Quartz; using Stripe; +using Stripe.TestHelpers; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -20,12 +24,16 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IStripeFacade _stripeFacade; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; - private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationRepository _organizationRepository; private readonly ISchedulerFactory _schedulerFactory; private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IPricingClient _pricingClient; + private readonly IFeatureService _featureService; + private readonly IProviderRepository _providerRepository; + private readonly IProviderService _providerService; + private readonly ILogger _logger; + private readonly IPushNotificationAdapter _pushNotificationAdapter; public SubscriptionUpdatedHandler( IStripeEventService stripeEventService, @@ -34,25 +42,35 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler IStripeFacade stripeFacade, IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand, IUserService userService, - IPushNotificationService pushNotificationService, IOrganizationRepository organizationRepository, ISchedulerFactory schedulerFactory, IOrganizationEnableCommand organizationEnableCommand, IOrganizationDisableCommand organizationDisableCommand, - IPricingClient pricingClient) + IPricingClient pricingClient, + IFeatureService featureService, + IProviderRepository providerRepository, + IProviderService providerService, + ILogger logger, + IPushNotificationAdapter pushNotificationAdapter) { _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; _organizationService = organizationService; + _providerService = providerService; _stripeFacade = stripeFacade; _organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand; _userService = userService; - _pushNotificationService = pushNotificationService; _organizationRepository = organizationRepository; + _providerRepository = providerRepository; _schedulerFactory = schedulerFactory; _organizationEnableCommand = organizationEnableCommand; _organizationDisableCommand = organizationDisableCommand; _pricingClient = pricingClient; + _featureService = featureService; + _providerRepository = providerRepository; + _providerService = providerService; + _logger = logger; + _pushNotificationAdapter = pushNotificationAdapter; } /// @@ -61,7 +79,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler /// public async Task HandleAsync(Event parsedEvent) { - var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice"]); + var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]); var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); switch (subscription.Status) @@ -77,6 +95,11 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } break; } + case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired when providerId.HasValue: + { + await HandleUnpaidProviderSubscriptionAsync(providerId.Value, parsedEvent, subscription); + break; + } case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired: { if (!userId.HasValue) @@ -101,7 +124,29 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); if (organization != null) { - await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); + await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); + } + break; + } + case StripeSubscriptionStatus.Active when providerId.HasValue: + { + var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); + if (!providerPortalTakeover) + { + break; + } + var provider = await _providerRepository.GetByIdAsync(providerId.Value); + if (provider != null) + { + provider.Enabled = true; + await _providerService.UpdateAsync(provider); + + if (IsProviderSubscriptionNowActive(parsedEvent, subscription)) + { + // Update the CancelAtPeriodEnd subscription option to prevent the now active provider subscription from being cancelled + var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }; + await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionUpdateOptions); + } } break; } @@ -111,7 +156,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler { await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); } - break; } } @@ -149,6 +193,36 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } } + /// + /// Checks if the provider subscription status has changed from a non-active to an active status type + /// If the previous status is already active(active,past-due,trialing),canceled,or null, then this will return false. + /// + /// The event containing the previous subscription status + /// The current subscription status + /// A boolean that represents whether the event status has changed from a non-active status to an active status + private static bool IsProviderSubscriptionNowActive(Event parsedEvent, Subscription subscription) + { + if (parsedEvent.Data.PreviousAttributes == null) + { + return false; + } + + var previousSubscription = parsedEvent + .Data + .PreviousAttributes + .ToObject() as Subscription; + + return previousSubscription?.Status switch + { + StripeSubscriptionStatus.IncompleteExpired + or StripeSubscriptionStatus.Paused + or StripeSubscriptionStatus.Incomplete + or StripeSubscriptionStatus.Unpaid + when subscription.Status == StripeSubscriptionStatus.Active => true, + _ => false + }; + } + /// /// Removes the Password Manager coupon if the organization is removing the Secrets Manager trial. /// Only applies to organizations that have a subscription from the Secrets Manager trial. @@ -238,4 +312,121 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler await scheduler.ScheduleJob(job, trigger); } + + private async Task HandleUnpaidProviderSubscriptionAsync( + Guid providerId, + Event parsedEvent, + Subscription currentSubscription) + { + var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); + + if (!providerPortalTakeover) + { + return; + } + + var provider = await _providerRepository.GetByIdAsync(providerId); + if (provider == null) + { + return; + } + + try + { + provider.Enabled = false; + await _providerService.UpdateAsync(provider); + + if (parsedEvent.Data.PreviousAttributes != null) + { + var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject() as Subscription; + + var updateIsSubscriptionGoingUnpaid = previousSubscription is + { + Status: + StripeSubscriptionStatus.Trialing or + StripeSubscriptionStatus.Active or + StripeSubscriptionStatus.PastDue + } && currentSubscription is + { + Status: StripeSubscriptionStatus.Unpaid, + LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create" + }; + + var updateIsManualSuspensionViaMetadata = CheckForManualSuspensionViaMetadata( + previousSubscription, currentSubscription); + + if (updateIsSubscriptionGoingUnpaid || updateIsManualSuspensionViaMetadata) + { + if (currentSubscription.TestClock != null) + { + await WaitForTestClockToAdvanceAsync(currentSubscription.TestClock); + } + + var now = currentSubscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + + var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) }; + + if (updateIsManualSuspensionViaMetadata) + { + subscriptionUpdateOptions.Metadata = new Dictionary + { + ["suspended_provider_via_webhook_at"] = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture) + }; + } + + await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions); + } + } + } + catch (Exception exception) + { + _logger.LogError(exception, "An error occurred while trying to disable and schedule subscription cancellation for provider ({ProviderID})", providerId); + } + } + + private async Task WaitForTestClockToAdvanceAsync(TestClock testClock) + { + while (testClock.Status != "ready") + { + await Task.Delay(TimeSpan.FromSeconds(2)); + testClock = await _stripeFacade.GetTestClock(testClock.Id); + if (testClock.Status == "internal_failure") + { + throw new Exception("Stripe Test Clock encountered an internal failure"); + } + } + } + + private static bool CheckForManualSuspensionViaMetadata( + Subscription? previousSubscription, + Subscription currentSubscription) + { + /* + * When metadata on a subscription is updated, we'll receive an event that has: + * Previous Metadata: { newlyAddedKey: null } + * Current Metadata: { newlyAddedKey: newlyAddedValue } + * + * As such, our check for a manual suspension must ensure that the 'previous_attributes' does contain the + * 'metadata' property, but also that the "suspend_provider" key in that metadata is set to null. + * + * If we don't do this and instead do a null coalescing check on 'previous_attributes?.metadata?.TryGetValue', + * we'll end up marking an event where 'previous_attributes.metadata' = null (which could be any subscription update + * that does not update the metadata) the same as a manual suspension. + */ + const string key = "suspend_provider"; + + if (previousSubscription is not { Metadata: not null } || + !previousSubscription.Metadata.TryGetValue(key, out var previousValue)) + { + return false; + } + + if (previousValue == null) + { + return !string.IsNullOrEmpty( + currentSubscription.Metadata.TryGetValue(key, out var currentValue) ? currentValue : null); + } + + return false; + } } diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index e31d1dceb7..e5675f7c0a 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,10 +1,14 @@ -using Bit.Core; +// FIXME: Update this file to be null safe and then delete the line below + +#nullable disable + using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; @@ -15,7 +19,7 @@ using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; public class UpcomingInvoiceHandler( - IFeatureService featureService, + IGetPaymentMethodQuery getPaymentMethodQuery, ILogger logger, IMailService mailService, IOrganizationRepository organizationRepository, @@ -45,8 +49,6 @@ public class UpcomingInvoiceHandler( var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); - var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); - if (organizationId.HasValue) { var organization = await organizationRepository.GetByIdAsync(organizationId.Value); @@ -56,7 +58,7 @@ public class UpcomingInvoiceHandler( return; } - await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge); + await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id); var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); @@ -135,9 +137,9 @@ public class UpcomingInvoiceHandler( return; } - await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id, setNonUSBusinessUseToReverseCharge); + await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id); - await SendUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice); + await SendProviderUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice, subscription, providerId.Value); } } @@ -158,48 +160,69 @@ public class UpcomingInvoiceHandler( } } + private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, Subscription subscription, Guid providerId) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + + var items = invoice.FormatForProvider(subscription); + + if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) + { + var provider = await providerRepository.GetByIdAsync(providerId); + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId); + return; + } + + var collectionMethod = subscription.CollectionMethod; + var paymentMethod = await getPaymentMethodQuery.Run(provider); + + var hasPaymentMethod = paymentMethod != null; + var paymentMethodDescription = paymentMethod?.Match( + bankAccount => $"Bank account ending in {bankAccount.Last4}", + card => $"{card.Brand} ending in {card.Last4}", + payPal => $"PayPal account {payPal.Email}" + ); + + await mailService.SendProviderInvoiceUpcoming( + validEmails, + invoice.AmountDue / 100M, + invoice.NextPaymentAttempt.Value, + items, + collectionMethod, + hasPaymentMethod, + paymentMethodDescription); + } + } + private async Task AlignOrganizationTaxConcernsAsync( Organization organization, Subscription subscription, - string eventId, - bool setNonUSBusinessUseToReverseCharge) + string eventId) { var nonUSBusinessUse = organization.PlanType.GetProductTier() != ProductTierType.Families && - subscription.Customer.Address.Country != "US"; + subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates; - bool setAutomaticTaxToEnabled; - - if (setNonUSBusinessUseToReverseCharge) + if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) { - if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) + try { - try - { - await stripeFacade.UpdateCustomer(subscription.CustomerId, - new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); - } - catch (Exception exception) - { - logger.LogError( - exception, - "Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}", - organization.Id, - eventId); - } + await stripeFacade.UpdateCustomer(subscription.CustomerId, + new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}", + organization.Id, + eventId); } - - setAutomaticTaxToEnabled = true; - } - else - { - setAutomaticTaxToEnabled = - subscription.Customer.HasRecognizedTaxLocation() && - (subscription.Customer.Address.Country == "US" || - (nonUSBusinessUse && subscription.Customer.TaxIds.Any())); } - if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled) + if (!subscription.AutomaticTax.Enabled) { try { @@ -223,41 +246,27 @@ public class UpcomingInvoiceHandler( private async Task AlignProviderTaxConcernsAsync( Provider provider, Subscription subscription, - string eventId, - bool setNonUSBusinessUseToReverseCharge) + string eventId) { - bool setAutomaticTaxToEnabled; - - if (setNonUSBusinessUseToReverseCharge) + if (subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates && + subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) { - if (subscription.Customer.Address.Country != "US" && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) + try { - try - { - await stripeFacade.UpdateCustomer(subscription.CustomerId, - new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); - } - catch (Exception exception) - { - logger.LogError( - exception, - "Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}", - provider.Id, - eventId); - } + await stripeFacade.UpdateCustomer(subscription.CustomerId, + new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}", + provider.Id, + eventId); } - - setAutomaticTaxToEnabled = true; - } - else - { - setAutomaticTaxToEnabled = - subscription.Customer.HasRecognizedTaxLocation() && - (subscription.Customer.Address.Country == "US" || - subscription.Customer.TaxIds.Any()); } - if (!subscription.AutomaticTax.Enabled && setAutomaticTaxToEnabled) + if (!subscription.AutomaticTax.Enabled) { try { diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index afb01f4801..5b464d5ef6 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -1,7 +1,11 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using System.Net.Http.Headers; using Bit.Billing.Services; using Bit.Billing.Services.Implementations; +using Bit.Commercial.Core.Utilities; using Bit.Core.Billing.Extensions; using Bit.Core.Context; using Bit.Core.SecretsManager.Repositories; @@ -69,6 +73,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); // Identity @@ -80,6 +85,7 @@ public class Startup services.AddDefaultServices(globalSettings); services.AddDistributedCache(globalSettings); services.AddBillingOperations(); + services.AddCommercialCoreServices(); services.TryAddSingleton(); @@ -106,6 +112,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Add Quartz services first services.AddQuartz(q => diff --git a/src/Billing/appsettings.Development.json b/src/Billing/appsettings.Development.json index 32253a93c1..7c4889c22f 100644 --- a/src/Billing/appsettings.Development.json +++ b/src/Billing/appsettings.Development.json @@ -31,5 +31,10 @@ "storage": { "connectionString": "UseDevelopmentStorage=true" } + }, + "billingSettings": { + "onyx": { + "personaId": 68 + } } } diff --git a/src/Billing/appsettings.Production.json b/src/Billing/appsettings.Production.json index 819986181f..4be5d51a52 100644 --- a/src/Billing/appsettings.Production.json +++ b/src/Billing/appsettings.Production.json @@ -26,7 +26,10 @@ "payPal": { "production": true, "businessId": "4ZDA7DLUUJGMN" - } + }, + "onyx": { + "personaId": 7 + } }, "Logging": { "IncludeScopes": false, diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index 2a2864b246..6c90c22686 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -72,11 +72,21 @@ "webhookKey": "SECRET", "region": "US", "userFieldName": "cf_user", - "orgFieldName": "cf_org" + "orgFieldName": "cf_org", + "removeNewlinesInReplies": true, + "autoReplyGreeting": "Greetings,

Thank you for contacting Bitwarden. The reply below was generated by our AI agent based on your message:

", + "autoReplySalutation": "

If this response doesn’t fully address your question, simply reply to this email and a member of our Customer Success team will be happy to assist you further.

Best Regards,
The Bitwarden Customer Success Team

" }, "onyx": { "apiKey": "SECRET", - "baseUrl": "https://cloud.onyx.app/api" + "baseUrl": "https://cloud.onyx.app/api", + "path": "/chat/send-message-simple-api", + "useAnswerWithCitationModels": true, + "personaId": 7, + "searchSettings": { + "runSearch": "always", + "realTime": true + } } } } diff --git a/src/Billing/entrypoint.sh b/src/Billing/entrypoint.sh index 66540416f5..8b6a312ea1 100644 --- a/src/Billing/entrypoint.sh +++ b/src/Billing/entrypoint.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # Setup diff --git a/src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs b/src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs new file mode 100644 index 0000000000..d0cecfb10d --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs @@ -0,0 +1,10 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public interface IApplicationCacheServiceBusMessaging +{ + Task NotifyOrganizationAbilityUpsertedAsync(Organization organization); + Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId); + Task NotifyProviderAbilityDeletedAsync(Guid providerId); +} diff --git a/src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs b/src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs new file mode 100644 index 0000000000..e8152b1e98 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public interface IVCurrentInMemoryApplicationCacheService +{ + Task> GetOrganizationAbilitiesAsync(); +#nullable enable + Task GetOrganizationAbilityAsync(Guid orgId); +#nullable disable + Task> GetProviderAbilitiesAsync(); + Task UpsertOrganizationAbilityAsync(Organization organization); + Task UpsertProviderAbilityAsync(Provider provider); + Task DeleteOrganizationAbilityAsync(Guid organizationId); + Task DeleteProviderAbilityAsync(Guid providerId); +} diff --git a/src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs b/src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs new file mode 100644 index 0000000000..57109ba6a7 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public interface IVNextInMemoryApplicationCacheService +{ + Task> GetOrganizationAbilitiesAsync(); +#nullable enable + Task GetOrganizationAbilityAsync(Guid orgId); +#nullable disable + Task> GetProviderAbilitiesAsync(); + Task UpsertOrganizationAbilityAsync(Organization organization); + Task UpsertProviderAbilityAsync(Provider provider); + Task DeleteOrganizationAbilityAsync(Guid organizationId); + Task DeleteProviderAbilityAsync(Guid providerId); +} diff --git a/src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs b/src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs new file mode 100644 index 0000000000..36a380a850 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs @@ -0,0 +1,21 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public class NoOpApplicationCacheMessaging : IApplicationCacheServiceBusMessaging +{ + public Task NotifyOrganizationAbilityUpsertedAsync(Organization organization) + { + return Task.CompletedTask; + } + + public Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId) + { + return Task.CompletedTask; + } + + public Task NotifyProviderAbilityDeletedAsync(Guid providerId) + { + return Task.CompletedTask; + } +} diff --git a/src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs b/src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs new file mode 100644 index 0000000000..f267871da7 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs @@ -0,0 +1,63 @@ +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Settings; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public class ServiceBusApplicationCacheMessaging : IApplicationCacheServiceBusMessaging +{ + private readonly ServiceBusSender _topicMessageSender; + private readonly string _subName; + + public ServiceBusApplicationCacheMessaging( + GlobalSettings globalSettings) + { + _subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings); + var serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString); + _topicMessageSender = serviceBusClient.CreateSender(globalSettings.ServiceBus.ApplicationCacheTopicName); + } + + public async Task NotifyOrganizationAbilityUpsertedAsync(Organization organization) + { + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.UpsertOrganizationAbility }, + { "id", organization.Id }, + } + }; + await _topicMessageSender.SendMessageAsync(message); + } + + public async Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId) + { + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.DeleteOrganizationAbility }, + { "id", organizationId }, + } + }; + await _topicMessageSender.SendMessageAsync(message); + } + + public async Task NotifyProviderAbilityDeletedAsync(Guid providerId) + { + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.DeleteProviderAbility }, + { "id", providerId }, + } + }; + await _topicMessageSender.SendMessageAsync(message); + } +} diff --git a/src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs b/src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs new file mode 100644 index 0000000000..409074e3b2 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public class VNextInMemoryApplicationCacheService( + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + TimeProvider timeProvider) : IVNextInMemoryApplicationCacheService +{ + private ConcurrentDictionary _orgAbilities = new(); + private readonly SemaphoreSlim _orgInitLock = new(1, 1); + private DateTimeOffset _lastOrgAbilityRefresh = DateTimeOffset.MinValue; + + private ConcurrentDictionary _providerAbilities = new(); + private readonly SemaphoreSlim _providerInitLock = new(1, 1); + private DateTimeOffset _lastProviderAbilityRefresh = DateTimeOffset.MinValue; + + private readonly TimeSpan _refreshInterval = TimeSpan.FromMinutes(10); + + public virtual async Task> GetOrganizationAbilitiesAsync() + { + await InitOrganizationAbilitiesAsync(); + return _orgAbilities; + } + + public async Task GetOrganizationAbilityAsync(Guid organizationId) + { + (await GetOrganizationAbilitiesAsync()) + .TryGetValue(organizationId, out var organizationAbility); + return organizationAbility; + } + + public virtual async Task> GetProviderAbilitiesAsync() + { + await InitProviderAbilitiesAsync(); + return _providerAbilities; + } + + public virtual async Task UpsertProviderAbilityAsync(Provider provider) + { + await InitProviderAbilitiesAsync(); + _providerAbilities.AddOrUpdate( + provider.Id, + static (_, provider) => new ProviderAbility(provider), + static (_, _, provider) => new ProviderAbility(provider), + provider); + } + + public virtual async Task UpsertOrganizationAbilityAsync(Organization organization) + { + await InitOrganizationAbilitiesAsync(); + + _orgAbilities.AddOrUpdate( + organization.Id, + static (_, organization) => new OrganizationAbility(organization), + static (_, _, organization) => new OrganizationAbility(organization), + organization); + } + + public virtual Task DeleteOrganizationAbilityAsync(Guid organizationId) + { + _orgAbilities.TryRemove(organizationId, out _); + return Task.CompletedTask; + } + + public virtual Task DeleteProviderAbilityAsync(Guid providerId) + { + _providerAbilities.TryRemove(providerId, out _); + return Task.CompletedTask; + } + + private async Task InitOrganizationAbilitiesAsync() => + await InitAbilitiesAsync( + dict => _orgAbilities = dict, + () => _lastOrgAbilityRefresh, + dt => _lastOrgAbilityRefresh = dt, + _orgInitLock, + async () => await organizationRepository.GetManyAbilitiesAsync(), + _refreshInterval, + ability => ability.Id); + + private async Task InitProviderAbilitiesAsync() => + await InitAbilitiesAsync( + dict => _providerAbilities = dict, + () => _lastProviderAbilityRefresh, + dateTime => _lastProviderAbilityRefresh = dateTime, + _providerInitLock, + async () => await providerRepository.GetManyAbilitiesAsync(), + _refreshInterval, + ability => ability.Id); + + + private async Task InitAbilitiesAsync( + Action> setCache, + Func getLastRefresh, + Action setLastRefresh, + SemaphoreSlim @lock, + Func>> fetchFunc, + TimeSpan refreshInterval, + Func getId) + { + if (SkipRefresh()) + { + return; + } + + await @lock.WaitAsync(); + try + { + if (SkipRefresh()) + { + return; + } + + var sources = await fetchFunc(); + var abilities = new ConcurrentDictionary( + sources.ToDictionary(getId)); + setCache(abilities); + setLastRefresh(timeProvider.GetUtcNow()); + } + finally + { + @lock.Release(); + } + + bool SkipRefresh() + { + return timeProvider.GetUtcNow() - getLastRefresh() <= refreshInterval; + } + } +} diff --git a/src/Core/AdminConsole/Context/CurrentContextOrganization.cs b/src/Core/AdminConsole/Context/CurrentContextOrganization.cs index 3c9dc10cc0..e154a5a25f 100644 --- a/src/Core/AdminConsole/Context/CurrentContextOrganization.cs +++ b/src/Core/AdminConsole/Context/CurrentContextOrganization.cs @@ -5,6 +5,10 @@ using Bit.Core.Utilities; namespace Bit.Core.Context; +/// +/// Represents the claims for a user in relation to a particular organization. +/// These claims will only be present for users in the status. +/// public class CurrentContextOrganization { public CurrentContextOrganization() { } diff --git a/src/Core/AdminConsole/Context/CurrentContextProvider.cs b/src/Core/AdminConsole/Context/CurrentContextProvider.cs index 78a5565e80..5be25171d0 100644 --- a/src/Core/AdminConsole/Context/CurrentContextProvider.cs +++ b/src/Core/AdminConsole/Context/CurrentContextProvider.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Models.Data; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/Entities/Event.cs b/src/Core/AdminConsole/Entities/Event.cs index 2a6b6664c2..e2868c1915 100644 --- a/src/Core/AdminConsole/Entities/Event.cs +++ b/src/Core/AdminConsole/Entities/Event.cs @@ -32,7 +32,9 @@ public class Event : ITableObject, IEvent SystemUser = e.SystemUser; DomainName = e.DomainName; SecretId = e.SecretId; + ProjectId = e.ProjectId; ServiceAccountId = e.ServiceAccountId; + GrantedServiceAccountId = e.GrantedServiceAccountId; } public Guid Id { get; set; } @@ -56,8 +58,9 @@ public class Event : ITableObject, IEvent public EventSystemUser? SystemUser { get; set; } public string? DomainName { get; set; } public Guid? SecretId { get; set; } + public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } - + public Guid? GrantedServiceAccountId { get; set; } public void SetNewId() { Id = CoreHelpers.GenerateComb(); diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index 274c7f8ddb..7933990e74 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -4,9 +4,9 @@ using System.Text.Json; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Business; using Bit.Core.Services; using Bit.Core.Utilities; @@ -30,6 +30,7 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable /// This value is HTML encoded. For display purposes use the method DisplayBusinessName() instead. ///
[MaxLength(50)] + [Obsolete("This property has been deprecated. Use the 'Name' property instead.")] public string? BusinessName { get; set; } [MaxLength(50)] public string? BusinessAddress1 { get; set; } @@ -123,6 +124,11 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable ///
public bool UseAdminSponsoredFamilies { get; set; } + /// + /// If set to true, organization needs their seat count synced with their subscription + /// + public bool SyncSeats { get; set; } + public void SetNewId() { if (Id == default(Guid)) @@ -142,6 +148,8 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable /// /// Returns the business name of the organization, HTML decoded ready for display. /// + /// + [Obsolete("This method has been deprecated. Use the 'DisplayName()' method instead.")] public string? DisplayBusinessName() { return WebUtility.HtmlDecode(BusinessName); diff --git a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs b/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs index 5c2250824e..52934cf7f3 100644 --- a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs @@ -10,7 +10,7 @@ public class OrganizationIntegrationConfiguration : ITableObject { public Guid Id { get; set; } public Guid OrganizationIntegrationId { get; set; } - public EventType EventType { get; set; } + public EventType? EventType { get; set; } public string? Configuration { get; set; } public string? Template { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/AdminConsole/Enums/EventType.cs index 9d9cb09989..8073938fc5 100644 --- a/src/Core/AdminConsole/Enums/EventType.cs +++ b/src/Core/AdminConsole/Enums/EventType.cs @@ -70,7 +70,16 @@ public enum EventType : int Organization_EnabledKeyConnector = 1606, Organization_DisabledKeyConnector = 1607, Organization_SponsorshipsSynced = 1608, + [Obsolete("Kept for historical data. Use specific Organization_CollectionManagement events instead.")] Organization_CollectionManagement_Updated = 1609, + Organization_CollectionManagement_LimitCollectionCreationEnabled = 1610, + Organization_CollectionManagement_LimitCollectionCreationDisabled = 1611, + Organization_CollectionManagement_LimitCollectionDeletionEnabled = 1612, + Organization_CollectionManagement_LimitCollectionDeletionDisabled = 1613, + Organization_CollectionManagement_LimitItemDeletionEnabled = 1614, + Organization_CollectionManagement_LimitItemDeletionDisabled = 1615, + Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled = 1616, + Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617, Policy_Updated = 1700, @@ -90,4 +99,21 @@ public enum EventType : int OrganizationDomain_NotVerified = 2003, Secret_Retrieved = 2100, + Secret_Created = 2101, + Secret_Edited = 2102, + Secret_Deleted = 2103, + Secret_Permanently_Deleted = 2104, + Secret_Restored = 2105, + + Project_Retrieved = 2200, + Project_Created = 2201, + Project_Edited = 2202, + Project_Deleted = 2203, + + ServiceAccount_UserAdded = 2300, + ServiceAccount_UserRemoved = 2301, + ServiceAccount_GroupAdded = 2302, + ServiceAccount_GroupRemoved = 2303, + ServiceAccount_Created = 2304, + ServiceAccount_Deleted = 2305, } diff --git a/src/Core/AdminConsole/Enums/IntegrationType.cs b/src/Core/AdminConsole/Enums/IntegrationType.cs index 5edd54df23..34edc71fbe 100644 --- a/src/Core/AdminConsole/Enums/IntegrationType.cs +++ b/src/Core/AdminConsole/Enums/IntegrationType.cs @@ -6,6 +6,8 @@ public enum IntegrationType : int Scim = 2, Slack = 3, Webhook = 4, + Hec = 5, + Datadog = 6 } public static class IntegrationTypeExtensions @@ -18,6 +20,10 @@ public static class IntegrationTypeExtensions return "slack"; case IntegrationType.Webhook: return "webhook"; + case IntegrationType.Hec: + return "hec"; + case IntegrationType.Datadog: + return "datadog"; default: throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}"); } diff --git a/src/Core/AdminConsole/Enums/OrganizationIntegrationStatus.cs b/src/Core/AdminConsole/Enums/OrganizationIntegrationStatus.cs new file mode 100644 index 0000000000..78a7bc6d63 --- /dev/null +++ b/src/Core/AdminConsole/Enums/OrganizationIntegrationStatus.cs @@ -0,0 +1,10 @@ +namespace Bit.Api.AdminConsole.Models.Response.Organizations; + +public enum OrganizationIntegrationStatus : int +{ + NotApplicable, + Invalid, + Initiated, + InProgress, + Completed +} diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index ab39e543f8..3ac14d67f3 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -18,6 +18,9 @@ public enum PolicyType : byte FreeFamiliesSponsorshipPolicy = 13, RemoveUnlockWithPin = 14, RestrictedItemTypesPolicy = 15, + UriMatchDefaults = 16, + AutotypeDefaultSetting = 17, + AutomaticUserConfirmation = 18, } public static class PolicyTypeExtensions @@ -46,6 +49,9 @@ public static class PolicyTypeExtensions PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship", PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN", PolicyType.RestrictedItemTypesPolicy => "Restricted item types", + PolicyType.UriMatchDefaults => "URI match defaults", + PolicyType.AutotypeDefaultSetting => "Autotype default setting", + PolicyType.AutomaticUserConfirmation => "Automatically confirm invited users", }; } } diff --git a/src/Core/AdminConsole/Models/Business/ImportedGroup.cs b/src/Core/AdminConsole/Models/Business/ImportedGroup.cs index bd4e81bf5b..7e4cddb496 100644 --- a/src/Core/AdminConsole/Models/Business/ImportedGroup.cs +++ b/src/Core/AdminConsole/Models/Business/ImportedGroup.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; namespace Bit.Core.AdminConsole.Models.Business; diff --git a/src/Core/AdminConsole/Models/Business/ImportedOrganizationUser.cs b/src/Core/AdminConsole/Models/Business/ImportedOrganizationUser.cs index 967cdf253d..273d2ee3b3 100644 --- a/src/Core/AdminConsole/Models/Business/ImportedOrganizationUser.cs +++ b/src/Core/AdminConsole/Models/Business/ImportedOrganizationUser.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Business; public class ImportedOrganizationUser { diff --git a/src/Core/AdminConsole/Models/Business/InviteOrganization.cs b/src/Core/AdminConsole/Models/Business/InviteOrganization.cs index 175ee07a9f..56b3259bc4 100644 --- a/src/Core/AdminConsole/Models/Business/InviteOrganization.cs +++ b/src/Core/AdminConsole/Models/Business/InviteOrganization.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.StaticStore; namespace Bit.Core.AdminConsole.Models.Business; diff --git a/src/Core/AdminConsole/Models/Business/OrganizationCollectionManagementSettings.cs b/src/Core/AdminConsole/Models/Business/OrganizationCollectionManagementSettings.cs new file mode 100644 index 0000000000..aff2244598 --- /dev/null +++ b/src/Core/AdminConsole/Models/Business/OrganizationCollectionManagementSettings.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.AdminConsole.Models.Business; + +public record OrganizationCollectionManagementSettings +{ + public bool LimitCollectionCreation { get; set; } + public bool LimitCollectionDeletion { get; set; } + public bool LimitItemDeletion { get; set; } + public bool AllowAdminAccessToAllCollectionItems { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Business/OrganizationUserInvite.cs b/src/Core/AdminConsole/Models/Business/OrganizationUserInvite.cs index e177a5047b..5b59102173 100644 --- a/src/Core/AdminConsole/Models/Business/OrganizationUserInvite.cs +++ b/src/Core/AdminConsole/Models/Business/OrganizationUserInvite.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; namespace Bit.Core.Models.Business; diff --git a/src/Core/AdminConsole/Models/Business/Provider/ProviderUserInvite.cs b/src/Core/AdminConsole/Models/Business/Provider/ProviderUserInvite.cs index 53cdefb3f9..061caffdd7 100644 --- a/src/Core/AdminConsole/Models/Business/Provider/ProviderUserInvite.cs +++ b/src/Core/AdminConsole/Models/Business/Provider/ProviderUserInvite.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums.Provider; namespace Bit.Core.AdminConsole.Models.Business.Provider; diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs new file mode 100644 index 0000000000..8785a74896 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record DatadogIntegration(string ApiKey, Uri Uri); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs new file mode 100644 index 0000000000..07aafa4bd8 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record DatadogIntegrationConfigurationDetails(string ApiKey, Uri Uri); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs new file mode 100644 index 0000000000..1c74826791 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class DatadogListenerConfiguration(GlobalSettings globalSettings) + : ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration +{ + public IntegrationType IntegrationType + { + get => IntegrationType.Datadog; + } + + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.DatadogEventsQueueName; + } + + public string IntegrationQueueName + { + get => _globalSettings.EventLogging.RabbitMq.DatadogIntegrationQueueName; + } + + public string IntegrationRetryQueueName + { + get => _globalSettings.EventLogging.RabbitMq.DatadogIntegrationRetryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.DatadogEventSubscriptionName; + } + + public string IntegrationSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.DatadogIntegrationSubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs new file mode 100644 index 0000000000..33ae5dadbe --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record HecIntegration(Uri Uri, string Scheme, string Token, string? Service = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecListenerConfiguration.cs new file mode 100644 index 0000000000..37a0d68beb --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecListenerConfiguration.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class HecListenerConfiguration(GlobalSettings globalSettings) + : ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration +{ + public IntegrationType IntegrationType + { + get => IntegrationType.Hec; + } + + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.HecEventsQueueName; + } + + public string IntegrationQueueName + { + get => _globalSettings.EventLogging.RabbitMq.HecIntegrationQueueName; + } + + public string IntegrationRetryQueueName + { + get => _globalSettings.EventLogging.RabbitMq.HecIntegrationRetryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.HecEventSubscriptionName; + } + + public string IntegrationSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.HecIntegrationSubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IEventListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IEventListenerConfiguration.cs new file mode 100644 index 0000000000..7df1459941 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IEventListenerConfiguration.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public interface IEventListenerConfiguration +{ + public string EventQueueName { get; } + public string EventSubscriptionName { get; } + public string EventTopicName { get; } + public int EventPrefetchCount { get; } + public int EventMaxConcurrentCalls { get; } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationListenerConfiguration.cs new file mode 100644 index 0000000000..30401bb072 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationListenerConfiguration.cs @@ -0,0 +1,20 @@ +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public interface IIntegrationListenerConfiguration : IEventListenerConfiguration +{ + public IntegrationType IntegrationType { get; } + public string IntegrationQueueName { get; } + public string IntegrationRetryQueueName { get; } + public string IntegrationSubscriptionName { get; } + public string IntegrationTopicName { get; } + public int MaxRetries { get; } + public int IntegrationPrefetchCount { get; } + public int IntegrationMaxConcurrentCalls { get; } + + public string RoutingKey + { + get => IntegrationType.ToRoutingKey(); + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs index f979b8af0e..7a0962d89a 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.Enums; +using Bit.Core.Enums; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs index bb0c2e01ba..276ca3a14b 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs @@ -1,6 +1,4 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public class IntegrationFilterGroup { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs index f09df47738..fddf630e26 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs @@ -1,5 +1,4 @@ -#nullable enable -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public enum IntegrationFilterOperation { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs index b9d90a0442..b5f90f5e63 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs @@ -1,6 +1,4 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public class IntegrationFilterRule { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs index d3b0c0d5ac..8db054561b 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs @@ -1,6 +1,4 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public class IntegrationHandlerResult { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs index 1861ec4522..11a5229f8c 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.Enums; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationOAuthState.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationOAuthState.cs new file mode 100644 index 0000000000..3b29bbebb4 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationOAuthState.cs @@ -0,0 +1,71 @@ +using System.Security.Cryptography; +using System.Text; +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class IntegrationOAuthState +{ + private const int _orgHashLength = 12; + private static readonly TimeSpan _maxAge = TimeSpan.FromMinutes(20); + + public Guid IntegrationId { get; } + private DateTimeOffset Issued { get; } + private string OrganizationIdHash { get; } + + private IntegrationOAuthState(Guid integrationId, string organizationIdHash, DateTimeOffset issued) + { + IntegrationId = integrationId; + OrganizationIdHash = organizationIdHash; + Issued = issued; + } + + public static IntegrationOAuthState FromIntegration(OrganizationIntegration integration, TimeProvider timeProvider) + { + var integrationId = integration.Id; + var issuedUtc = timeProvider.GetUtcNow(); + var organizationIdHash = ComputeOrgHash(integration.OrganizationId, issuedUtc.ToUnixTimeSeconds()); + + return new IntegrationOAuthState(integrationId, organizationIdHash, issuedUtc); + } + + public static IntegrationOAuthState? FromString(string state, TimeProvider timeProvider) + { + if (string.IsNullOrWhiteSpace(state)) return null; + + var parts = state.Split('.'); + if (parts.Length != 3) return null; + + // Verify timestamp + if (!long.TryParse(parts[2], out var unixSeconds)) return null; + + var issuedUtc = DateTimeOffset.FromUnixTimeSeconds(unixSeconds); + var now = timeProvider.GetUtcNow(); + var age = now - issuedUtc; + + if (age > _maxAge) return null; + + // Parse integration id and store org + if (!Guid.TryParse(parts[0], out var integrationId)) return null; + var organizationIdHash = parts[1]; + + return new IntegrationOAuthState(integrationId, organizationIdHash, issuedUtc); + } + + public bool ValidateOrg(Guid orgId) + { + var expected = ComputeOrgHash(orgId, Issued.ToUnixTimeSeconds()); + return expected == OrganizationIdHash; + } + + public override string ToString() + { + return $"{IntegrationId}.{OrganizationIdHash}.{Issued.ToUnixTimeSeconds()}"; + } + + private static string ComputeOrgHash(Guid orgId, long timestamp) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{orgId:N}:{timestamp}")); + return Convert.ToHexString(bytes)[.._orgHashLength]; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs index 82c236865f..79a30c3a02 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs @@ -1,5 +1,4 @@ -#nullable enable - +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Enums; @@ -25,6 +24,8 @@ public class IntegrationTemplateContext(EventMessage eventMessage) public Guid? GroupId => Event.GroupId; public Guid? PolicyId => Event.PolicyId; + public string EventMessage => JsonSerializer.Serialize(Event); + public User? User { get; set; } public string? UserName => User?.Name; public string? UserEmail => User?.Email; diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/ListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/ListenerConfiguration.cs new file mode 100644 index 0000000000..40eb2b3e77 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/ListenerConfiguration.cs @@ -0,0 +1,48 @@ +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public abstract class ListenerConfiguration +{ + protected GlobalSettings _globalSettings; + + public ListenerConfiguration(GlobalSettings globalSettings) + { + _globalSettings = globalSettings; + } + + public int MaxRetries + { + get => _globalSettings.EventLogging.MaxRetries; + } + + public string EventTopicName + { + get => _globalSettings.EventLogging.AzureServiceBus.EventTopicName; + } + + public string IntegrationTopicName + { + get => _globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName; + } + + public int EventPrefetchCount + { + get => _globalSettings.EventLogging.AzureServiceBus.DefaultPrefetchCount; + } + + public int EventMaxConcurrentCalls + { + get => _globalSettings.EventLogging.AzureServiceBus.DefaultMaxConcurrentCalls; + } + + public int IntegrationPrefetchCount + { + get => _globalSettings.EventLogging.AzureServiceBus.DefaultPrefetchCount; + } + + public int IntegrationMaxConcurrentCalls + { + get => _globalSettings.EventLogging.AzureServiceBus.DefaultMaxConcurrentCalls; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/RepositoryListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/RepositoryListenerConfiguration.cs new file mode 100644 index 0000000000..118b3a17fe --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/RepositoryListenerConfiguration.cs @@ -0,0 +1,17 @@ +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class RepositoryListenerConfiguration(GlobalSettings globalSettings) + : ListenerConfiguration(globalSettings), IEventListenerConfiguration +{ + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.EventRepositorySubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs index e8bfaee303..dc2733c889 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record SlackIntegration(string Token); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs index 2c757aeb76..5b4fae0c76 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record SlackIntegrationConfiguration(string ChannelId); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs index 6c3d4c2fff..d22f43bb92 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record SlackIntegrationConfigurationDetails(string ChannelId, string Token); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackListenerConfiguration.cs new file mode 100644 index 0000000000..7dd834f51e --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackListenerConfiguration.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class SlackListenerConfiguration(GlobalSettings globalSettings) : + ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration +{ + public IntegrationType IntegrationType + { + get => IntegrationType.Slack; + } + + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.SlackEventsQueueName; + } + + public string IntegrationQueueName + { + get => _globalSettings.EventLogging.RabbitMq.SlackIntegrationQueueName; + } + + public string IntegrationRetryQueueName + { + get => _globalSettings.EventLogging.RabbitMq.SlackIntegrationRetryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.SlackEventSubscriptionName; + } + + public string IntegrationSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.SlackIntegrationSubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs new file mode 100644 index 0000000000..dcda4caa92 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record WebhookIntegration(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs index ff28edc301..851bd3f411 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs @@ -1,5 +1,3 @@ -#nullable enable +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; - -public record WebhookIntegrationConfiguration(string Url, string? Scheme = null, string? Token = null); +public record WebhookIntegrationConfiguration(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs index e0ed5dfcfa..dba9b1714d 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs @@ -1,5 +1,3 @@ -#nullable enable +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; - -public record WebhookIntegrationConfigurationDetails(string Url, string? Scheme = null, string? Token = null); +public record WebhookIntegrationConfigurationDetails(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookListenerConfiguration.cs new file mode 100644 index 0000000000..9d5bf811c7 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookListenerConfiguration.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class WebhookListenerConfiguration(GlobalSettings globalSettings) + : ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration +{ + public IntegrationType IntegrationType + { + get => IntegrationType.Webhook; + } + + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.WebhookEventsQueueName; + } + + public string IntegrationQueueName + { + get => _globalSettings.EventLogging.RabbitMq.WebhookIntegrationQueueName; + } + + public string IntegrationRetryQueueName + { + get => _globalSettings.EventLogging.RabbitMq.WebhookIntegrationRetryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.WebhookEventSubscriptionName; + } + + public string IntegrationSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.WebhookIntegrationSubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventMessage.cs b/src/Core/AdminConsole/Models/Data/EventMessage.cs index 6d2a1f2b4e..a29d70c203 100644 --- a/src/Core/AdminConsole/Models/Data/EventMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventMessage.cs @@ -1,4 +1,7 @@ -using Bit.Core.Context; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Context; using Bit.Core.Enums; namespace Bit.Core.Models.Data; @@ -34,5 +37,7 @@ public class EventMessage : IEvent public EventSystemUser? SystemUser { get; set; } public string DomainName { get; set; } public Guid? SecretId { get; set; } + public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } + public Guid? GrantedServiceAccountId { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/EventTableEntity.cs b/src/Core/AdminConsole/Models/Data/EventTableEntity.cs index 7e863b128c..1c3023f2cf 100644 --- a/src/Core/AdminConsole/Models/Data/EventTableEntity.cs +++ b/src/Core/AdminConsole/Models/Data/EventTableEntity.cs @@ -1,4 +1,7 @@ -using Azure; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Azure; using Azure.Data.Tables; using Bit.Core.Enums; using Bit.Core.Utilities; @@ -32,7 +35,9 @@ public class AzureEvent : ITableEntity public int? SystemUser { get; set; } public string DomainName { get; set; } public Guid? SecretId { get; set; } + public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } + public Guid? GrantedServiceAccountId { get; set; } public EventTableEntity ToEventTableEntity() { @@ -62,7 +67,9 @@ public class AzureEvent : ITableEntity SystemUser = SystemUser.HasValue ? (EventSystemUser)SystemUser.Value : null, DomainName = DomainName, SecretId = SecretId, - ServiceAccountId = ServiceAccountId + ServiceAccountId = ServiceAccountId, + ProjectId = ProjectId, + GrantedServiceAccountId = GrantedServiceAccountId }; } } @@ -92,7 +99,9 @@ public class EventTableEntity : IEvent SystemUser = e.SystemUser; DomainName = e.DomainName; SecretId = e.SecretId; + ProjectId = e.ProjectId; ServiceAccountId = e.ServiceAccountId; + GrantedServiceAccountId = e.GrantedServiceAccountId; } public string PartitionKey { get; set; } @@ -119,7 +128,9 @@ public class EventTableEntity : IEvent public EventSystemUser? SystemUser { get; set; } public string DomainName { get; set; } public Guid? SecretId { get; set; } + public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } + public Guid? GrantedServiceAccountId { get; set; } public AzureEvent ToAzureEvent() { @@ -149,7 +160,9 @@ public class EventTableEntity : IEvent SystemUser = SystemUser.HasValue ? (int)SystemUser.Value : null, DomainName = DomainName, SecretId = SecretId, - ServiceAccountId = ServiceAccountId + ProjectId = ProjectId, + ServiceAccountId = ServiceAccountId, + GrantedServiceAccountId = GrantedServiceAccountId }; } @@ -215,6 +228,24 @@ public class EventTableEntity : IEvent }); } + if (e.ProjectId.HasValue) + { + entities.Add(new EventTableEntity(e) + { + PartitionKey = pKey, + RowKey = $"ProjectId={e.ProjectId}__Date={dateKey}__Uniquifier={uniquifier}" + }); + } + + if (e.GrantedServiceAccountId.HasValue) + { + entities.Add(new EventTableEntity(e) + { + PartitionKey = pKey, + RowKey = $"GrantedServiceAccountId={e.GrantedServiceAccountId}__Date={dateKey}__Uniquifier={uniquifier}" + }); + } + return entities; } diff --git a/src/Core/AdminConsole/Models/Data/GroupWithCollections.cs b/src/Core/AdminConsole/Models/Data/GroupWithCollections.cs index e9ba512574..6ec47990ae 100644 --- a/src/Core/AdminConsole/Models/Data/GroupWithCollections.cs +++ b/src/Core/AdminConsole/Models/Data/GroupWithCollections.cs @@ -1,4 +1,7 @@ -using System.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; using Bit.Core.AdminConsole.Entities; namespace Bit.Core.AdminConsole.Models.Data; diff --git a/src/Core/AdminConsole/Models/Data/IEvent.cs b/src/Core/AdminConsole/Models/Data/IEvent.cs index 6a177e39ca..3188c905e4 100644 --- a/src/Core/AdminConsole/Models/Data/IEvent.cs +++ b/src/Core/AdminConsole/Models/Data/IEvent.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; namespace Bit.Core.Models.Data; @@ -23,5 +26,7 @@ public interface IEvent EventSystemUser? SystemUser { get; set; } string DomainName { get; set; } Guid? SecretId { get; set; } + Guid? ProjectId { get; set; } Guid? ServiceAccountId { get; set; } + Guid? GrantedServiceAccountId { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs index b5d224c012..5fdc760c90 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs @@ -8,9 +8,10 @@ namespace Bit.Core.Models.Data.Organizations; public class OrganizationIntegrationConfigurationDetails { public Guid Id { get; set; } + public Guid OrganizationId { get; set; } public Guid OrganizationIntegrationId { get; set; } public IntegrationType IntegrationType { get; set; } - public EventType EventType { get; set; } + public EventType? EventType { get; set; } public string? Configuration { get; set; } public string? Filters { get; set; } public string? IntegrationConfiguration { get; set; } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationSubscriptionUpdate.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationSubscriptionUpdate.cs new file mode 100644 index 0000000000..ec66a6a94e --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationSubscriptionUpdate.cs @@ -0,0 +1,11 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.AdminConsole.Models.Data.Organizations; + +public record OrganizationSubscriptionUpdate +{ + public required Organization Organization { get; set; } + public int Seats => Organization.Seats ?? 0; + public Plan? Plan { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserInviteData.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserInviteData.cs index a48ee3a6c4..7f1034f50e 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserInviteData.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserInviteData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index 8de422ee31..b7e573c4e6 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Utilities; @@ -46,6 +49,7 @@ public class OrganizationUserOrganizationDetails public string ProviderName { get; set; } public ProviderType? ProviderType { get; set; } public string FamilySponsorshipFriendlyName { get; set; } + public bool? SsoEnabled { get; set; } public string SsoConfig { get; set; } public DateTime? FamilySponsorshipLastSyncDate { get; set; } public DateTime? FamilySponsorshipValidUntil { get; set; } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPolicyDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPolicyDetails.cs index 84939ecf79..0f5f5fd7c6 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPolicyDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPolicyDetails.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.Enums; namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPublicKey.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPublicKey.cs index 7c04967872..b51675bdc5 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPublicKey.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPublicKey.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; public class OrganizationUserPublicKey { diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserResetPasswordDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserResetPasswordDetails.cs index 05d6807fad..f2ed0c0ba2 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserResetPasswordDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserResetPasswordDetails.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs index 64ee316ab6..6d182e197f 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Interfaces; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserWithCollections.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserWithCollections.cs index d86c6c1581..02d83597e2 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserWithCollections.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserWithCollections.cs @@ -1,4 +1,7 @@ -using System.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; using Bit.Core.Entities; namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; diff --git a/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs b/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs index f2f275b708..b66244ba5f 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs @@ -1,20 +1,28 @@ -namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using System.Text.Json.Serialization; +namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies; public class MasterPasswordPolicyData : IPolicyDataModel { + [JsonPropertyName("minComplexity")] public int? MinComplexity { get; set; } + [JsonPropertyName("minLength")] public int? MinLength { get; set; } + [JsonPropertyName("requireLower")] public bool? RequireLower { get; set; } + [JsonPropertyName("requireUpper")] public bool? RequireUpper { get; set; } + [JsonPropertyName("requireNumbers")] public bool? RequireNumbers { get; set; } + [JsonPropertyName("requireSpecial")] public bool? RequireSpecial { get; set; } + [JsonPropertyName("enforceOnLogin")] public bool? EnforceOnLogin { get; set; } /// /// Combine the other policy data with this instance, taking the most secure options /// /// The other policy instance to combine with this - public void CombineWith(MasterPasswordPolicyData other) + public void CombineWith(MasterPasswordPolicyData? other) { if (other == null) { diff --git a/src/Core/AdminConsole/Models/Data/Organizations/Policies/OrganizationPolicyDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/Policies/OrganizationPolicyDetails.cs new file mode 100644 index 0000000000..eab0c9456f --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Organizations/Policies/OrganizationPolicyDetails.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies; + +public class OrganizationPolicyDetails : PolicyDetails +{ + public Guid UserId { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index a6ad47f829..84ff164943 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -1,11 +1,14 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Business; namespace Bit.Core.Models.Data.Organizations; diff --git a/src/Core/AdminConsole/Models/Data/Permissions.cs b/src/Core/AdminConsole/Models/Data/Permissions.cs index def468f18d..75bf2db8c9 100644 --- a/src/Core/AdminConsole/Models/Data/Permissions.cs +++ b/src/Core/AdminConsole/Models/Data/Permissions.cs @@ -1,5 +1,5 @@ using System.Text.Json.Serialization; -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; namespace Bit.Core.Models.Data; diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationOrganizationDetails.cs index 9d84f60c4c..0a6d255774 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationOrganizationDetails.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using System.Text.Json.Serialization; using Bit.Core.Billing.Enums; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationProviderDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationProviderDetails.cs index 629e0bae53..77ca501526 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationProviderDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationProviderDetails.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index 4621de8268..04281d098e 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs index 67565bad6d..2ab06bacae 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserPublicKey.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserPublicKey.cs index a9b37b2050..18a0679702 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserPublicKey.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserPublicKey.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.AdminConsole.Models.Data.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.AdminConsole.Models.Data.Provider; public class ProviderUserPublicKey { diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserUserDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserUserDetails.cs index d42437a26e..97f72f7137 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserUserDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserUserDetails.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/Models/Mail/DeviceApprovalRequestedViewModel.cs b/src/Core/AdminConsole/Models/Mail/DeviceApprovalRequestedViewModel.cs index 7f6c932619..892d077296 100644 --- a/src/Core/AdminConsole/Models/Mail/DeviceApprovalRequestedViewModel.cs +++ b/src/Core/AdminConsole/Models/Mail/DeviceApprovalRequestedViewModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.AdminConsole.Models.Mail; diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessor.cs b/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessor.cs index 59b5025eeb..da33289630 100644 --- a/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessor.cs +++ b/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessor.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Reflection; using Bit.Core.Auth.Models.Data; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessor.cs b/src/Core/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessor.cs index 3ebcd1fb51..76d5c4b321 100644 --- a/src/Core/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessor.cs +++ b/src/Core/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessor.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models.Data; using Bit.Core.Enums; namespace Bit.Core.AdminConsole.OrganizationAuth.Models; diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdate.cs b/src/Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdate.cs index 5a4b4ed763..c829ed0ad6 100644 --- a/src/Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdate.cs +++ b/src/Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdate.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.AdminConsole.OrganizationAuth.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.AdminConsole.OrganizationAuth.Models; public class OrganizationAuthRequestUpdate { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs index f514beed38..86a222439e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/ICreateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/ICreateGroupCommand.cs index b3ad06d9dc..13a5a00f43 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/ICreateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/ICreateGroupCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/IUpdateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/IUpdateGroupCommand.cs index 1cae4805f2..4ef95ceeae 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/IUpdateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/IUpdateGroupCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommand.cs index 1b53716537..3347e77c37 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommand.cs @@ -163,6 +163,11 @@ public class UpdateGroupCommand : IUpdateGroupCommand // Use generic error message to avoid enumeration throw new NotFoundException(); } + + if (collections.Any(c => c.Type == CollectionType.DefaultUserCollection)) + { + throw new BadRequestException("You cannot modify group access for collections with the type as DefaultUserCollection."); + } } private async Task ValidateMemberAccessAsync(Group originalGroup, diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs new file mode 100644 index 0000000000..b74da0a2e8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs @@ -0,0 +1,16 @@ +#nullable enable + +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IImportOrganizationUsersAndGroupsCommand +{ + Task ImportAsync(Guid organizationId, + IEnumerable groups, + IEnumerable newUsers, + IEnumerable removeUserExternalIds, + bool overwriteExisting + ); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs new file mode 100644 index 0000000000..a78dd95260 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs @@ -0,0 +1,388 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Import; + +public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersAndGroupsCommand +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IPaymentService _paymentService; + private readonly IGroupRepository _groupRepository; + private readonly IEventService _eventService; + private readonly IOrganizationService _organizationService; + + private readonly EventSystemUser _EventSystemUser = EventSystemUser.PublicApi; + + public ImportOrganizationUsersAndGroupsCommand(IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPaymentService paymentService, + IGroupRepository groupRepository, + IEventService eventService, + IOrganizationService organizationService) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _paymentService = paymentService; + _groupRepository = groupRepository; + _eventService = eventService; + _organizationService = organizationService; + } + + /// + /// Imports and synchronizes organization users and groups. + /// + /// The unique identifier of the organization. + /// List of groups to import. + /// List of users to import. + /// A collection of ExternalUserIds to be removed from the organization. + /// Indicates whether to delete existing external users from the organization + /// who are not included in the current import. + /// Thrown if the organization does not exist. + /// Thrown if the organization is not configured to use directory syncing. + public async Task ImportAsync(Guid organizationId, + IEnumerable importedGroups, + IEnumerable importedUsers, + IEnumerable removeUserExternalIds, + bool overwriteExisting) + { + var organization = await GetOrgById(organizationId); + if (organization is null) + { + throw new NotFoundException(); + } + + if (!organization.UseDirectory) + { + throw new BadRequestException("Organization cannot use directory syncing."); + } + + var existingUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + var importUserData = new OrganizationUserImportData(existingUsers, importedUsers); + var events = new List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)>(); + + await RemoveExistingExternalUsers(removeUserExternalIds, events, importUserData); + + if (overwriteExisting) + { + await OverwriteExisting(events, importUserData); + } + + await UpdateExistingUsers(importedUsers, importUserData); + + await AddNewUsers(organization, importedUsers, importUserData); + + await ImportGroups(organization, importedGroups, importUserData); + + await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.ou, e.e, _EventSystemUser, e.d))); + } + + /// + /// Deletes external users based on provided set of ExternalIds. + /// + /// A collection of external user IDs to be deleted. + /// A list to which user removal events will be added. + /// Data containing imported and existing external users. + + private async Task RemoveExistingExternalUsers(IEnumerable removeUserExternalIds, + List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)> events, + OrganizationUserImportData importUserData) + { + if (!removeUserExternalIds.Any()) + { + return; + } + + var existingUsersDict = importUserData.ExistingExternalUsers.ToDictionary(u => u.ExternalId); + // Determine which ids in removeUserExternalIds to delete based on: + // They are not in ImportedExternalIds, they are in existingUsersDict, and they are not an owner. + var removeUsersSet = new HashSet(removeUserExternalIds) + .Except(importUserData.ImportedExternalIds) + .Where(u => existingUsersDict.ContainsKey(u) && existingUsersDict[u].Type != OrganizationUserType.Owner) + .Select(u => existingUsersDict[u]); + + await _organizationUserRepository.DeleteManyAsync(removeUsersSet.Select(u => u.Id)); + events.AddRange(removeUsersSet.Select(u => ( + u, + EventType.OrganizationUser_Removed, + (DateTime?)DateTime.UtcNow + )) + ); + } + + /// + /// Updates existing organization users by assigning each an ExternalId from the imported user data + /// where a match is found by email and the existing user lacks an ExternalId. Saves the updated + /// users and updates the ExistingExternalUsersIdDict mapping. + /// + /// List of imported organization users. + /// Data containing existing and imported users, along with mapping dictionaries. + private async Task UpdateExistingUsers(IEnumerable importedUsers, OrganizationUserImportData importUserData) + { + if (!importedUsers.Any()) + { + return; + } + + var updateUsers = new List(); + + // Map existing and imported users to dicts keyed by Email + var existingUsersEmailsDict = importUserData.ExistingUsers + .Where(u => string.IsNullOrWhiteSpace(u.ExternalId)) + .ToDictionary(u => u.Email); + var importedUsersEmailsDict = importedUsers.ToDictionary(u => u.Email); + + // Determine which users to update. + var userEmailsToUpdate = existingUsersEmailsDict.Keys.Intersect(importedUsersEmailsDict.Keys).ToList(); + var userIdsToUpdate = userEmailsToUpdate.Select(e => existingUsersEmailsDict[e].Id).ToList(); + + var organizationUsers = (await _organizationUserRepository.GetManyAsync(userIdsToUpdate)).ToDictionary(u => u.Id); + + foreach (var userEmail in userEmailsToUpdate) + { + // verify userEmail has an associated OrganizationUser + existingUsersEmailsDict.TryGetValue(userEmail, out var existingUser); + organizationUsers.TryGetValue(existingUser!.Id, out var organizationUser); + importedUsersEmailsDict.TryGetValue(userEmail, out var importedUser); + + if (organizationUser is null || importedUser is null) + { + continue; + } + + organizationUser.ExternalId = importedUser.ExternalId; + updateUsers.Add(organizationUser); + importUserData.ExistingExternalUsersIdDict.Add(organizationUser.ExternalId, organizationUser.Id); + } + await _organizationUserRepository.UpsertManyAsync(updateUsers); + } + + /// + /// Adds new external users to the organization by inviting users who are present in the imported data + /// but not already part of the organization. Sends invitations, updates the user Id mapping on success, + /// and throws exceptions on failure. + /// + /// The target organization to which users are being added. + /// A collection of imported users to consider for addition. + /// Data containing imported user info and existing user mappings. + private async Task AddNewUsers(Organization organization, + IEnumerable importedUsers, + OrganizationUserImportData importUserData) + { + // Determine which users are already in the organization + var existingUsersSet = new HashSet(importUserData.ExistingExternalUsersIdDict.Keys); + var usersToAdd = importUserData.ImportedExternalIds.Except(existingUsersSet).ToList(); + var userInvites = new List<(OrganizationUserInvite, string)>(); + var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); + + foreach (var user in importedUsers) + { + if (!usersToAdd.Contains(user.ExternalId) || string.IsNullOrWhiteSpace(user.Email)) + { + continue; + } + + try + { + var invite = new OrganizationUserInvite + { + Emails = new List { user.Email }, + Type = OrganizationUserType.User, + Collections = new List(), + AccessSecretsManager = hasStandaloneSecretsManager + }; + userInvites.Add((invite, user.ExternalId)); + } + catch (BadRequestException) + { + // Thrown when the user is already invited to the organization + continue; + } + } + + var invitedUsers = await _organizationService.InviteUsersAsync(organization.Id, Guid.Empty, _EventSystemUser, userInvites); + foreach (var invitedUser in invitedUsers) + { + importUserData.ExistingExternalUsersIdDict.TryAdd(invitedUser.ExternalId!, invitedUser.Id); + } + } + + /// + /// Deletes existing external users from the organization who are not included in the current import and are not owners. + /// Records corresponding removal events and updates the internal mapping by removing deleted users. + /// + /// A list to which user removal events will be added. + /// Data containing existing and imported external users along with their Id mappings. + private async Task OverwriteExisting( + List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)> events, + OrganizationUserImportData importUserData) + { + var usersToDelete = importUserData.ExistingExternalUsers + .Where(u => + u.Type != OrganizationUserType.Owner && + !importUserData.ImportedExternalIds.Contains(u.ExternalId) && + importUserData.ExistingExternalUsersIdDict.ContainsKey(u.ExternalId)) + .ToList(); + + if (usersToDelete.Any(u => !u.HasMasterPassword)) + { + // Removing users without an MP will put their account in an unrecoverable state. + // We allow this during normal syncs for offboarding, but overwriteExisting risks bricking every user in + // the organization, so you don't get to do it here. + throw new BadRequestException( + "Sync failed. To proceed, disable the 'Remove and re-add users during next sync' setting and try again."); + } + + await _organizationUserRepository.DeleteManyAsync(usersToDelete.Select(u => u.Id)); + events.AddRange(usersToDelete.Select(u => ( + u, + EventType.OrganizationUser_Removed, + (DateTime?)DateTime.UtcNow + )) + ); + foreach (var deletedUser in usersToDelete) + { + importUserData.ExistingExternalUsersIdDict.Remove(deletedUser.ExternalId); + } + } + + /// + /// Imports group data into the organization by saving new groups and updating existing ones. + /// + /// The organization into which groups are being imported. + /// A collection of groups to be imported. + /// Data containing information about existing and imported users. + private async Task ImportGroups(Organization organization, IEnumerable importedGroups, OrganizationUserImportData importUserData) + { + if (!importedGroups.Any()) + { + return; + } + + if (!organization.UseGroups) + { + throw new BadRequestException("Organization cannot use groups."); + } + + var existingGroups = await _groupRepository.GetManyByOrganizationIdAsync(organization.Id); + var importGroupData = new OrganizationGroupImportData(importedGroups, existingGroups); + + await SaveNewGroups(importGroupData, importUserData); + await UpdateExistingGroups(importGroupData, importUserData, organization); + } + + /// + /// Saves newly imported groups that do not already exist in the organization. + /// Sets their creation and revision dates, associates users with each group. + /// + /// Data containing both imported and existing groups. + /// Data containing information about existing and imported users. + private async Task SaveNewGroups(OrganizationGroupImportData importGroupData, OrganizationUserImportData importUserData) + { + var existingExternalGroupsDict = importGroupData.ExistingExternalGroups.ToDictionary(g => g.ExternalId!); + var newGroups = importGroupData.Groups + .Where(g => !existingExternalGroupsDict.ContainsKey(g.Group.ExternalId!)) + .Select(g => g.Group) + .ToList()!; + + var savedGroups = new List(); + foreach (var group in newGroups) + { + group.CreationDate = group.RevisionDate = DateTime.UtcNow; + + savedGroups.Add(await _groupRepository.CreateAsync(group)); + await UpdateUsersAsync(group, importGroupData.GroupsDict[group.ExternalId!].ExternalUserIds, + importUserData.ExistingExternalUsersIdDict); + } + + await _eventService.LogGroupEventsAsync( + savedGroups.Select(g => (g, EventType.Group_Created, (EventSystemUser?)_EventSystemUser, (DateTime?)DateTime.UtcNow))); + } + + /// + /// Updates existing groups in the organization based on imported group data. + /// If a group's name has changed, it updates the name and revision date in the repository. + /// Also updates group-user associations. + /// + /// Data containing imported groups and their user associations. + /// Data containing imported and existing organization users. + /// The organization to which the groups belong. + private async Task UpdateExistingGroups(OrganizationGroupImportData importGroupData, + OrganizationUserImportData importUserData, + Organization organization) + { + var updateGroups = importGroupData.ExistingExternalGroups + .Where(g => importGroupData.GroupsDict.ContainsKey(g.ExternalId!)) + .ToList(); + + if (updateGroups.Any()) + { + // get existing group users + var groupUsers = await _groupRepository.GetManyGroupUsersByOrganizationIdAsync(organization.Id); + var existingGroupUsers = groupUsers + .GroupBy(gu => gu.GroupId) + .ToDictionary(g => g.Key, g => new HashSet(g.Select(gr => gr.OrganizationUserId))); + + foreach (var group in updateGroups) + { + // Check for changes to the group, update if changed. + var updatedGroup = importGroupData.GroupsDict[group.ExternalId!].Group; + if (group.Name != updatedGroup.Name) + { + group.RevisionDate = DateTime.UtcNow; + group.Name = updatedGroup.Name; + + await _groupRepository.ReplaceAsync(group); + } + + // compare and update user group associations + await UpdateUsersAsync(group, importGroupData.GroupsDict[group.ExternalId!].ExternalUserIds, + importUserData.ExistingExternalUsersIdDict, + existingGroupUsers.ContainsKey(group.Id) ? existingGroupUsers[group.Id] : null); + + } + + await _eventService.LogGroupEventsAsync( + updateGroups.Select(g => (g, EventType.Group_Updated, (EventSystemUser?)_EventSystemUser, (DateTime?)DateTime.UtcNow))); + } + + } + + /// + /// Updates the user associations for a given group. + /// Only updates if the set of associated users differs from the current group membership. + /// Filters users based on those present in the existing user Id dictionary. + /// + /// The group whose user associations are being updated. + /// A set of ExternalUserIds to be associated with the group. + /// A dictionary mapping ExternalUserIds to internal user Ids. + /// Optional set of currently associated user Ids for comparison. + private async Task UpdateUsersAsync(Group group, HashSet groupUsers, + Dictionary existingUsersIdDict, HashSet? existingUsers = null) + { + var availableUsers = groupUsers.Intersect(existingUsersIdDict.Keys); + var users = new HashSet(availableUsers.Select(u => existingUsersIdDict[u])); + if (existingUsers is not null && existingUsers.Count == users.Count && users.SetEquals(existingUsers)) + { + return; + } + + await _groupRepository.UpdateUsersAsync(group.Id, users); + } + + private async Task GetOrgById(Guid id) + { + return await _organizationRepository.GetByIdAsync(id); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationGroupImportData.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationGroupImportData.cs new file mode 100644 index 0000000000..6f49cb82e6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationGroupImportData.cs @@ -0,0 +1,41 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.Models.Data.Organizations; + +/// +/// Represents the data required to import organization groups, +/// including newly imported groups and existing groups within the organization. +/// +public class OrganizationGroupImportData +{ + /// + /// The collection of groups that are being imported. + /// + public readonly IEnumerable Groups; + + /// + /// Collection of groups that already exist in the organization. + /// + public readonly ICollection ExistingGroups; + + /// + /// Existing groups with ExternalId set. + /// + public readonly IEnumerable ExistingExternalGroups; + + /// + /// Mapping of imported groups keyed by their ExternalId. + /// + public readonly IDictionary GroupsDict; + + public OrganizationGroupImportData(IEnumerable groups, ICollection existingGroups) + { + Groups = groups; + GroupsDict = groups.ToDictionary(g => g.Group.ExternalId!); + ExistingGroups = existingGroups; + ExistingExternalGroups = existingGroups.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationUserImportData.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationUserImportData.cs new file mode 100644 index 0000000000..6575afe842 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationUserImportData.cs @@ -0,0 +1,32 @@ +#nullable enable + +using Bit.Core.Models.Business; +namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; + +public class OrganizationUserImportData +{ + /// + /// Set of user ExternalIds that are being imported + /// + public readonly HashSet ImportedExternalIds; + /// + /// All existing OrganizationUsers for the organization + /// + public readonly ICollection ExistingUsers; + /// + /// Existing OrganizationUsers with ExternalIds set. + /// + public readonly IEnumerable ExistingExternalUsers; + /// + /// Mapping of an existing users's ExternalId to their Id + /// + public readonly Dictionary ExistingExternalUsersIdDict; + + public OrganizationUserImportData(ICollection existingUsers, IEnumerable importedUsers) + { + ImportedExternalIds = new HashSet(importedUsers?.Select(u => u.ExternalId) ?? new List()); + ExistingUsers = existingUsers; + ExistingExternalUsers = ExistingUsers.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); + ExistingExternalUsersIdDict = ExistingExternalUsers.ToDictionary(u => u.ExternalId, u => u.Id); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationApiKeys/GetOrganizationApiKeyQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationApiKeys/GetOrganizationApiKeyQuery.cs index b91e57a67c..f4a3b96372 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationApiKeys/GetOrganizationApiKeyQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationApiKeys/GetOrganizationApiKeyQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationConnections/ValidateBillingSyncKeyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationConnections/ValidateBillingSyncKeyCommand.cs index 3e19c773ef..ccc56297df 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationConnections/ValidateBillingSyncKeyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationConnections/ValidateBillingSyncKeyCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/GetOrganizationDomainByIdOrganizationIdQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/GetOrganizationDomainByIdOrganizationIdQuery.cs index 12616a142a..5f9c102208 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/GetOrganizationDomainByIdOrganizationIdQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/GetOrganizationDomainByIdOrganizationIdQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.Entities; using Bit.Core.Repositories; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index 43a3120ffd..c03341bbc0 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index 3770d867cf..63f177b3f3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/OrganizationUserUserMiniDetailsAuthorizationHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/OrganizationUserUserMiniDetailsAuthorizationHandler.cs deleted file mode 100644 index 01b77a05b3..0000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/OrganizationUserUserMiniDetailsAuthorizationHandler.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; -using Bit.Core.Context; -using Microsoft.AspNetCore.Authorization; - -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; - -public class OrganizationUserUserMiniDetailsAuthorizationHandler : - AuthorizationHandler -{ - private readonly ICurrentContext _currentContext; - - public OrganizationUserUserMiniDetailsAuthorizationHandler(ICurrentContext currentContext) - { - _currentContext = currentContext; - } - - protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, - OrganizationUserUserMiniDetailsOperationRequirement requirement, OrganizationScope organizationScope) - { - var authorized = false; - - switch (requirement) - { - case not null when requirement.Name == nameof(OrganizationUserUserMiniDetailsOperations.ReadAll): - authorized = await CanReadAllAsync(organizationScope); - break; - } - - if (authorized) - { - context.Succeed(requirement); - } - } - - private async Task CanReadAllAsync(Guid organizationId) - { - // All organization users can access this data to manage collection access - var organization = _currentContext.GetOrganization(organizationId); - if (organization != null) - { - return true; - } - - // Providers can also access this to manage the organization generally - return await _currentContext.ProviderUserForOrgAsync(organizationId); - } -} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 62e5d60191..2fbe6be5c6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -64,7 +67,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, string defaultUserCollectionName = null) { - var result = await ConfirmUsersAsync( + var result = await SaveChangesToDatabaseAsync( organizationId, new Dictionary() { { organizationUserId, key } }, confirmingUserId); @@ -80,12 +83,34 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand throw new BadRequestException(error); } - await HandleConfirmationSideEffectsAsync(organizationId, orgUser, defaultUserCollectionName); + await CreateDefaultCollectionAsync(orgUser, defaultUserCollectionName); return orgUser; } public async Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, + Guid confirmingUserId, string defaultUserCollectionName = null) + { + var result = await SaveChangesToDatabaseAsync(organizationId, keys, confirmingUserId); + + var confirmedOrganizationUsers = result + .Where(r => string.IsNullOrEmpty(r.Item2)) + .Select(r => r.Item1) + .ToList(); + + if (confirmedOrganizationUsers.Count == 1) + { + await CreateDefaultCollectionAsync(confirmedOrganizationUsers.Single(), defaultUserCollectionName); + } + else if (confirmedOrganizationUsers.Count > 1) + { + await CreateManyDefaultCollectionsAsync(organizationId, confirmedOrganizationUsers, defaultUserCollectionName); + } + + return result; + } + + private async Task>> SaveChangesToDatabaseAsync(Guid organizationId, Dictionary keys, Guid confirmingUserId) { var selectedOrganizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys); @@ -141,7 +166,6 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager); - await DeleteAndPushUserRegistrationAsync(organizationId, user.Id); succeededUsers.Add(orgUser); result.Add(Tuple.Create(orgUser, "")); } @@ -152,6 +176,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } await _organizationUserRepository.ReplaceManyAsync(succeededUsers); + await DeleteAndPushUserRegistrationAsync(organizationId, succeededUsers.Select(u => u.UserId!.Value)); return result; } @@ -205,12 +230,15 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } } - private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId) + private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, IEnumerable userIds) { - var devices = await GetUserDeviceIdsAsync(userId); - await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, - organizationId.ToString()); - await _pushNotificationService.PushSyncOrgKeysAsync(userId); + foreach (var userId in userIds) + { + var devices = await GetUserDeviceIdsAsync(userId); + await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, + organizationId.ToString()); + await _pushNotificationService.PushSyncOrgKeysAsync(userId); + } } private async Task> GetUserDeviceIdsAsync(Guid userId) @@ -221,53 +249,81 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand .Select(d => d.Id.ToString()); } - private async Task HandleConfirmationSideEffectsAsync(Guid organizationId, OrganizationUser organizationUser, string defaultUserCollectionName) - { - // Create DefaultUserCollection type collection for the user if the OrganizationDataOwnership policy is enabled for the organization - var requiresDefaultCollection = await OrganizationRequiresDefaultCollectionAsync(organizationId, organizationUser.UserId.Value, defaultUserCollectionName); - if (requiresDefaultCollection) - { - await CreateDefaultCollectionAsync(organizationId, organizationUser.Id, defaultUserCollectionName); - } - } - - private async Task OrganizationRequiresDefaultCollectionAsync(Guid organizationId, Guid userId, string defaultUserCollectionName) + /// + /// Creates a default collection for a single user if required by the Organization Data Ownership policy. + /// + /// The organization user who has just been confirmed. + /// The encrypted default user collection name. + private async Task CreateDefaultCollectionAsync(OrganizationUser organizationUser, string defaultUserCollectionName) { if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) { - return false; + return; } // Skip if no collection name provided (backwards compatibility) if (string.IsNullOrWhiteSpace(defaultUserCollectionName)) { - return false; + return; } - var organizationDataOwnershipRequirement = await _policyRequirementQuery.GetAsync(userId); - return organizationDataOwnershipRequirement.RequiresDefaultCollection(organizationId); - } - - private async Task CreateDefaultCollectionAsync(Guid organizationId, Guid organizationUserId, string defaultCollectionName) - { - var collection = new Collection + var organizationDataOwnershipPolicy = + await _policyRequirementQuery.GetAsync(organizationUser.UserId!.Value); + if (!organizationDataOwnershipPolicy.RequiresDefaultCollectionOnConfirm(organizationUser.OrganizationId)) { - OrganizationId = organizationId, - Name = defaultCollectionName, + return; + } + + var defaultCollection = new Collection + { + OrganizationId = organizationUser.OrganizationId, + Name = defaultUserCollectionName, Type = CollectionType.DefaultUserCollection }; - - var userAccess = new List + var collectionUser = new CollectionAccessSelection { - new CollectionAccessSelection - { - Id = organizationUserId, - ReadOnly = false, - HidePasswords = false, - Manage = true - } + Id = organizationUser.Id, + ReadOnly = false, + HidePasswords = false, + Manage = true }; - await _collectionRepository.CreateAsync(collection, groups: null, users: userAccess); + await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]); + } + + /// + /// Creates default collections for multiple users if required by the Organization Data Ownership policy. + /// + /// The organization ID. + /// The confirmed organization users. + /// The encrypted default user collection name. + private async Task CreateManyDefaultCollectionsAsync(Guid organizationId, + IEnumerable confirmedOrganizationUsers, string defaultUserCollectionName) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) + { + return; + } + + // Skip if no collection name provided (backwards compatibility) + if (string.IsNullOrWhiteSpace(defaultUserCollectionName)) + { + return; + } + + var policyEligibleOrganizationUserIds = + await _policyRequirementQuery.GetManyByOrganizationIdAsync(organizationId); + + var eligibleOrganizationUserIds = confirmedOrganizationUsers + .Where(ou => policyEligibleOrganizationUserIds.Contains(ou.Id)) + .Select(ou => ou.Id) + .ToList(); + + if (eligibleOrganizationUserIds.Count == 0) + { + return; + } + + await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs new file mode 100644 index 0000000000..fbb00a908a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs @@ -0,0 +1,42 @@ +using OneOf; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; + +/// +/// Represents the result of a command. +/// This is a type that contains an Error if the command execution failed, or the result of the command if it succeeded. +/// +/// The type of the successful result. If there is no successful result (void), use . + +public class CommandResult(OneOf result) : OneOfBase(result) +{ + public bool IsError => IsT0; + public bool IsSuccess => IsT1; + public Error AsError => AsT0; + public T AsSuccess => AsT1; + + public static implicit operator CommandResult(T value) => new(value); + public static implicit operator CommandResult(Error error) => new(error); +} + +/// +/// Represents the result of a command where successful execution returns no value (void). +/// See for more information. +/// +public class CommandResult(OneOf result) : CommandResult(result) +{ + public static implicit operator CommandResult(None none) => new(none); + public static implicit operator CommandResult(Error error) => new(error); +} + +/// +/// A wrapper for with an ID, to identify the result in bulk operations. +/// +public record BulkCommandResult(Guid Id, CommandResult Result); + +/// +/// A wrapper for with an ID, to identify the result in bulk operations. +/// +public record BulkCommandResult(Guid Id, CommandResult Result); + diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs new file mode 100644 index 0000000000..87c24c3ab4 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs @@ -0,0 +1,137 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; + +public class DeleteClaimedOrganizationUserAccountCommand( + IUserService userService, + IEventService eventService, + IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, + IOrganizationUserRepository organizationUserRepository, + IUserRepository userRepository, + IPushNotificationService pushService, + ILogger logger, + IDeleteClaimedOrganizationUserAccountValidator deleteClaimedOrganizationUserAccountValidator) + : IDeleteClaimedOrganizationUserAccountCommand +{ + public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId) + { + var result = await DeleteManyUsersAsync(organizationId, [organizationUserId], deletingUserId); + return result.Single(); + } + + public async Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid deletingUserId) + { + orgUserIds = orgUserIds.ToList(); + var orgUsers = await organizationUserRepository.GetManyAsync(orgUserIds); + var users = await GetUsersAsync(orgUsers); + var claimedStatuses = await getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds); + + var internalRequests = CreateInternalRequests(organizationId, deletingUserId, orgUserIds, orgUsers, users, claimedStatuses); + var validationResults = (await deleteClaimedOrganizationUserAccountValidator.ValidateAsync(internalRequests)).ToList(); + + var validRequests = validationResults.ValidRequests(); + await CancelPremiumsAsync(validRequests); + await HandleUserDeletionsAsync(validRequests); + await LogDeletedOrganizationUsersAsync(validRequests); + + return validationResults.Select(v => v.Match( + error => new BulkCommandResult(v.Request.OrganizationUserId, error), + _ => new BulkCommandResult(v.Request.OrganizationUserId, new None()) + )); + } + + private static IEnumerable CreateInternalRequests( + Guid organizationId, + Guid deletingUserId, + IEnumerable orgUserIds, + ICollection orgUsers, + IEnumerable users, + IDictionary claimedStatuses) + { + foreach (var orgUserId in orgUserIds) + { + var orgUser = orgUsers.FirstOrDefault(orgUser => orgUser.Id == orgUserId); + var user = users.FirstOrDefault(user => user.Id == orgUser?.UserId); + claimedStatuses.TryGetValue(orgUserId, out var isClaimed); + + yield return new DeleteUserValidationRequest + { + User = user, + OrganizationUserId = orgUserId, + OrganizationUser = orgUser, + IsClaimed = isClaimed, + OrganizationId = organizationId, + DeletingUserId = deletingUserId, + }; + } + } + + private async Task> GetUsersAsync(ICollection orgUsers) + { + var userIds = orgUsers + .Where(orgUser => orgUser.UserId.HasValue) + .Select(orgUser => orgUser.UserId!.Value) + .ToList(); + + return await userRepository.GetManyAsync(userIds); + } + + private async Task LogDeletedOrganizationUsersAsync(IEnumerable requests) + { + var eventDate = DateTime.UtcNow; + + var events = requests + .Select(request => (request.OrganizationUser!, EventType.OrganizationUser_Deleted, (DateTime?)eventDate)) + .ToList(); + + if (events.Count != 0) + { + await eventService.LogOrganizationUserEventsAsync(events); + } + } + + private async Task HandleUserDeletionsAsync(IEnumerable requests) + { + var users = requests + .Select(request => request.User!) + .ToList(); + + if (users.Count == 0) + { + return; + } + + await userRepository.DeleteManyAsync(users); + + foreach (var user in users) + { + await pushService.PushLogOutAsync(user.Id); + } + } + + private async Task CancelPremiumsAsync(IEnumerable requests) + { + var users = requests.Select(request => request.User!); + + foreach (var user in users) + { + try + { + await userService.CancelPremiumAsync(user); + } + catch (GatewayException exception) + { + logger.LogWarning(exception, "Failed to cancel premium subscription for {userId}.", user.Id); + } + } + } +} + diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs new file mode 100644 index 0000000000..315d45ea69 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs @@ -0,0 +1,76 @@ +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount.ValidationResultHelpers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; + +public class DeleteClaimedOrganizationUserAccountValidator( + ICurrentContext currentContext, + IOrganizationUserRepository organizationUserRepository, + IProviderUserRepository providerUserRepository) : IDeleteClaimedOrganizationUserAccountValidator +{ + public async Task>> ValidateAsync(IEnumerable requests) + { + var tasks = requests.Select(ValidateAsync); + var results = await Task.WhenAll(tasks); + return results; + } + + private async Task> ValidateAsync(DeleteUserValidationRequest request) + { + // Ensure user exists + if (request.User == null || request.OrganizationUser == null) + { + return Invalid(request, new UserNotFoundError()); + } + + // Cannot delete invited users + if (request.OrganizationUser.Status == OrganizationUserStatusType.Invited) + { + return Invalid(request, new InvalidUserStatusError()); + } + + // Cannot delete yourself + if (request.OrganizationUser.UserId == request.DeletingUserId) + { + return Invalid(request, new CannotDeleteYourselfError()); + } + + // Can only delete a claimed user + if (!request.IsClaimed) + { + return Invalid(request, new UserNotClaimedError()); + } + + // Cannot delete an owner unless you are an owner or provider + if (request.OrganizationUser.Type == OrganizationUserType.Owner && + !await currentContext.OrganizationOwner(request.OrganizationId)) + { + return Invalid(request, new CannotDeleteOwnersError()); + } + + // Cannot delete a user who is the sole owner of an organization + var onlyOwnerCount = await organizationUserRepository.GetCountByOnlyOwnerAsync(request.User.Id); + if (onlyOwnerCount > 0) + { + return Invalid(request, new SoleOwnerError()); + } + + // Cannot delete a user who is the sole member of a provider + var onlyOwnerProviderCount = await providerUserRepository.GetCountByOnlyOwnerAsync(request.User.Id); + if (onlyOwnerProviderCount > 0) + { + return Invalid(request, new SoleProviderError()); + } + + // Custom users cannot delete admins + if (request.OrganizationUser.Type == OrganizationUserType.Admin && await currentContext.OrganizationCustom(request.OrganizationId)) + { + return Invalid(request, new CannotDeleteAdminsError()); + } + + return Valid(request); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteUserValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteUserValidationRequest.cs new file mode 100644 index 0000000000..067d7ce04c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteUserValidationRequest.cs @@ -0,0 +1,13 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; + +public class DeleteUserValidationRequest +{ + public Guid OrganizationId { get; init; } + public Guid OrganizationUserId { get; init; } + public OrganizationUser? OrganizationUser { get; init; } + public User? User { get; init; } + public Guid DeletingUserId { get; init; } + public bool IsClaimed { get; init; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs new file mode 100644 index 0000000000..6c8f7ee00c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs @@ -0,0 +1,21 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; + +/// +/// A strongly typed error containing a reason that an action failed. +/// This is used for business logic validation and other expected errors, not exceptions. +/// +public abstract record Error(string Message); +/// +/// An type that maps to a NotFoundResult at the api layer. +/// +/// +public abstract record NotFoundError(string Message) : Error(Message); + +public record UserNotFoundError() : NotFoundError("Invalid user."); +public record UserNotClaimedError() : Error("Member is not claimed by the organization."); +public record InvalidUserStatusError() : Error("You cannot delete a member with Invited status."); +public record CannotDeleteYourselfError() : Error("You cannot delete yourself."); +public record CannotDeleteOwnersError() : Error("Only owners can delete other owners."); +public record SoleOwnerError() : Error("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user."); +public record SoleProviderError() : Error("Cannot delete this user because it is the sole owner of at least one provider. Please delete these providers or upgrade another user."); +public record CannotDeleteAdminsError() : Error("Custom users can not delete admins."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs similarity index 55% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs index 1c79687be9..983a3a4f21 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs @@ -1,13 +1,11 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; public interface IDeleteClaimedOrganizationUserAccountCommand { /// /// Removes a user from an organization and deletes all of their associated user data. /// - Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId); + Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId); /// /// Removes multiple users from an organization and deletes all of their associated user data. @@ -15,5 +13,5 @@ public interface IDeleteClaimedOrganizationUserAccountCommand /// /// An error message for each user that could not be removed, otherwise null. /// - Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid? deletingUserId); + Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid deletingUserId); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs new file mode 100644 index 0000000000..f1a2c71b1b --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; + +public interface IDeleteClaimedOrganizationUserAccountValidator +{ + Task>> ValidateAsync(IEnumerable requests); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs new file mode 100644 index 0000000000..c84a0aeda1 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs @@ -0,0 +1,41 @@ +using OneOf; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; + +/// +/// Represents the result of validating a request. +/// This is for use within the Core layer, e.g. validating a command request. +/// +/// The request that has been validated. +/// A type that contains an Error if validation failed. +/// The request type. +public class ValidationResult(TRequest request, OneOf error) : OneOfBase(error) +{ + public TRequest Request { get; } = request; + + public bool IsError => IsT0; + public bool IsValid => IsT1; + public Error AsError => AsT0; +} + +public static class ValidationResultHelpers +{ + /// + /// Creates a successful with no error set. + /// + public static ValidationResult Valid(T request) => new(request, new None()); + /// + /// Creates a failed with the specified error. + /// + public static ValidationResult Invalid(T request, Error error) => new(request, error); + + /// + /// Extracts successfully validated requests from a sequence of . + /// + public static List ValidRequests(this IEnumerable> results) => + results + .Where(r => r.IsValid) + .Select(r => r.Request) + .ToList(); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs deleted file mode 100644 index 60a1c8bfbf..0000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs +++ /dev/null @@ -1,239 +0,0 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Context; -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.Exceptions; -using Bit.Core.Platform.Push; -using Bit.Core.Repositories; -using Bit.Core.Services; - -#nullable enable - -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; - -public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganizationUserAccountCommand -{ - private readonly IUserService _userService; - private readonly IEventService _eventService; - private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IUserRepository _userRepository; - private readonly ICurrentContext _currentContext; - private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; - private readonly IPushNotificationService _pushService; - private readonly IOrganizationRepository _organizationRepository; - private readonly IProviderUserRepository _providerUserRepository; - public DeleteClaimedOrganizationUserAccountCommand( - IUserService userService, - IEventService eventService, - IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, - IOrganizationUserRepository organizationUserRepository, - IUserRepository userRepository, - ICurrentContext currentContext, - IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IPushNotificationService pushService, - IOrganizationRepository organizationRepository, - IProviderUserRepository providerUserRepository) - { - _userService = userService; - _eventService = eventService; - _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; - _organizationUserRepository = organizationUserRepository; - _userRepository = userRepository; - _currentContext = currentContext; - _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; - _pushService = pushService; - _organizationRepository = organizationRepository; - _providerUserRepository = providerUserRepository; - } - - public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId) - { - var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - if (organizationUser == null || organizationUser.OrganizationId != organizationId) - { - throw new NotFoundException("Member not found."); - } - - var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, new[] { organizationUserId }); - var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }, includeProvider: true); - - await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners); - - var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value); - if (user == null) - { - throw new NotFoundException("Member not found."); - } - - await _userService.DeleteAsync(user); - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Deleted); - } - - public async Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid? deletingUserId) - { - var orgUsers = await _organizationUserRepository.GetManyAsync(orgUserIds); - var userIds = orgUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId!.Value).ToList(); - var users = await _userRepository.GetManyAsync(userIds); - - var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds); - var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, orgUserIds, includeProvider: true); - - var results = new List<(Guid OrganizationUserId, string? ErrorMessage)>(); - foreach (var orgUserId in orgUserIds) - { - try - { - var orgUser = orgUsers.FirstOrDefault(ou => ou.Id == orgUserId); - if (orgUser == null || orgUser.OrganizationId != organizationId) - { - throw new NotFoundException("Member not found."); - } - - await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners); - - var user = users.FirstOrDefault(u => u.Id == orgUser.UserId); - if (user == null) - { - throw new NotFoundException("Member not found."); - } - - await ValidateUserMembershipAndPremiumAsync(user); - - results.Add((orgUserId, string.Empty)); - } - catch (Exception ex) - { - results.Add((orgUserId, ex.Message)); - } - } - - var orgUserResultsToDelete = results.Where(result => string.IsNullOrEmpty(result.ErrorMessage)); - var orgUsersToDelete = orgUsers.Where(orgUser => orgUserResultsToDelete.Any(result => orgUser.Id == result.OrganizationUserId)); - var usersToDelete = users.Where(user => orgUsersToDelete.Any(orgUser => orgUser.UserId == user.Id)); - - if (usersToDelete.Any()) - { - await DeleteManyAsync(usersToDelete); - } - - await LogDeletedOrganizationUsersAsync(orgUsers, results); - - return results; - } - - private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary claimedStatus, bool hasOtherConfirmedOwners) - { - if (!orgUser.UserId.HasValue || orgUser.Status == OrganizationUserStatusType.Invited) - { - throw new BadRequestException("You cannot delete a member with Invited status."); - } - - if (deletingUserId.HasValue && orgUser.UserId.Value == deletingUserId.Value) - { - throw new BadRequestException("You cannot delete yourself."); - } - - if (orgUser.Type == OrganizationUserType.Owner) - { - if (deletingUserId.HasValue && !await _currentContext.OrganizationOwner(organizationId)) - { - throw new BadRequestException("Only owners can delete other owners."); - } - - if (!hasOtherConfirmedOwners) - { - throw new BadRequestException("Organization must have at least one confirmed owner."); - } - } - - if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(organizationId)) - { - throw new BadRequestException("Custom users can not delete admins."); - } - - if (!claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) || !isClaimed) - { - throw new BadRequestException("Member is not claimed by the organization."); - } - } - - private async Task LogDeletedOrganizationUsersAsync( - IEnumerable orgUsers, - IEnumerable<(Guid OrgUserId, string? ErrorMessage)> results) - { - var eventDate = DateTime.UtcNow; - var events = new List<(OrganizationUser OrgUser, EventType Event, DateTime? EventDate)>(); - - foreach (var (orgUserId, errorMessage) in results) - { - var orgUser = orgUsers.FirstOrDefault(ou => ou.Id == orgUserId); - // If the user was not found or there was an error, we skip logging the event - if (orgUser == null || !string.IsNullOrEmpty(errorMessage)) - { - continue; - } - - events.Add((orgUser, EventType.OrganizationUser_Deleted, eventDate)); - } - - if (events.Any()) - { - await _eventService.LogOrganizationUserEventsAsync(events); - } - } - private async Task DeleteManyAsync(IEnumerable users) - { - - await _userRepository.DeleteManyAsync(users); - foreach (var user in users) - { - await _pushService.PushLogOutAsync(user.Id); - } - - } - - private async Task ValidateUserMembershipAndPremiumAsync(User user) - { - // Check if user is the only owner of any organizations. - var onlyOwnerCount = await _organizationUserRepository.GetCountByOnlyOwnerAsync(user.Id); - if (onlyOwnerCount > 0) - { - throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user."); - } - - var orgs = await _organizationUserRepository.GetManyDetailsByUserAsync(user.Id, OrganizationUserStatusType.Confirmed); - if (orgs.Count == 1) - { - var org = await _organizationRepository.GetByIdAsync(orgs.First().OrganizationId); - if (org != null && (!org.Enabled || string.IsNullOrWhiteSpace(org.GatewaySubscriptionId))) - { - var orgCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(org.Id); - if (orgCount <= 1) - { - await _organizationRepository.DeleteAsync(org); - } - else - { - throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user."); - } - } - } - - var onlyOwnerProviderCount = await _providerUserRepository.GetCountByOnlyOwnerAsync(user.Id); - if (onlyOwnerProviderCount > 0) - { - throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one provider. Please delete these providers or upgrade another user."); - } - - if (!string.IsNullOrWhiteSpace(user.GatewaySubscriptionId)) - { - try - { - await _userService.CancelPremiumAsync(user); - } - catch (GatewayException) { } - } - } -} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs index 734b8d2b0c..aca4853b66 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Exceptions; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; @@ -26,7 +29,8 @@ public interface IConfirmOrganizationUserCommand /// The ID of the organization. /// A dictionary mapping organization user IDs to their encrypted organization keys. /// The ID of the user performing the confirmation. + /// Optional encrypted collection name for creating default collections. /// A list of tuples containing the organization user and an error message (if any). Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, - Guid confirmingUserId); + Guid confirmingUserId, string defaultUserCollectionName = null); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..01ad2f05d2 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs @@ -0,0 +1,12 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IRevokeOrganizationUserCommand +{ + Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId); + Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); + Task>> RevokeUsersAsync(Guid organizationId, + IEnumerable organizationUserIds, Guid? revokingUserId); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs index 7e0a8dc3cd..7e8fd4c30a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs @@ -19,4 +19,14 @@ public interface IInviteOrganizationUsersCommand /// /// Response from InviteScimOrganiation Task> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request); + /// + /// Sends invitations to add imported organization users via the public API. + /// This can be a Success or a Failure. Failure will contain the Error along with a representation of the errored value. + /// Success will be the successful return object. + /// + /// + /// Contains the details for inviting the imported organization users. + /// + /// Response from InviteOrganiationUsersAsync + Task> InviteImportedOrganizationUsersAsync(InviteOrganizationUsersRequest request); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IResendOrganizationInviteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IResendOrganizationInviteCommand.cs new file mode 100644 index 0000000000..645cdb42d2 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IResendOrganizationInviteCommand.cs @@ -0,0 +1,14 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public interface IResendOrganizationInviteCommand +{ + /// + /// Resend an invite to an organization user. + /// + /// The ID of the organization. + /// The ID of the user who is inviting the organization user. + /// The ID of the organization user to resend the invite to. + /// Whether to initialize the organization. + /// This is should only be true when inviting the owner of a new organization. + Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index 1dddc8bf0c..6899959b8d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Interfaces; using Bit.Core.AdminConsole.Models.Business; @@ -9,20 +12,19 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.AdminConsole.Utilities.Errors; using Bit.Core.AdminConsole.Utilities.Validation; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; -using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; public class InviteOrganizationUsersCommand(IEventService eventService, IOrganizationUserRepository organizationUserRepository, IInviteUsersValidator inviteUsersValidator, - IPaymentService paymentService, IOrganizationRepository organizationRepository, IApplicationCacheService applicationCacheService, IMailService mailService, @@ -71,6 +73,40 @@ public class InviteOrganizationUsersCommand(IEventService eventService, } } + public async Task> InviteImportedOrganizationUsersAsync(InviteOrganizationUsersRequest request) + { + var result = await InviteOrganizationUsersAsync(request); + + switch (result) + { + case Failure failure: + return new Failure( + new Error( + failure.Error.Message, + new InviteOrganizationUsersResponse(failure.Error.ErroredValue.InvitedUsers, request.InviteOrganization.OrganizationId) + ) + ); + + case Success success when success.Value.InvitedUsers.Any(): + + List<(OrganizationUser, EventType, EventSystemUser, DateTime?)> events = new List<(OrganizationUser, EventType, EventSystemUser, DateTime?)>(); + foreach (var user in success.Value.InvitedUsers) + { + events.Add((user, EventType.OrganizationUser_Invited, EventSystemUser.PublicApi, request.PerformedAt.UtcDateTime)); + } + + await eventService.LogOrganizationUserEventsAsync(events); + + return new Success(new InviteOrganizationUsersResponse(success.Value.InvitedUsers, request.InviteOrganization.OrganizationId) + ); + + default: + return new Failure( + new InvalidResultTypeError( + new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId))); + } + } + private async Task> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request) { var invitesToSend = (await FilterExistingUsersAsync(request)).ToArray(); @@ -138,7 +174,7 @@ public class InviteOrganizationUsersCommand(IEventService eventService, organizationId: organization!.Id)); } - private async Task> FilterExistingUsersAsync(InviteOrganizationUsersRequest request) + private async Task> FilterExistingUsersAsync(InviteOrganizationUsersRequest request) { var existingEmails = new HashSet(await organizationUserRepository.SelectKnownEmailsAsync( request.InviteOrganization.OrganizationId, request.Invites.Select(i => i.Email), false), @@ -153,12 +189,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService, { if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { Seats: > 0, SeatsRequiredToAdd: > 0 }) { - - - await paymentService.AdjustSeatsAsync(organization, - validatedResult.Value.InviteOrganization.Plan, - validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats.Value); - organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats; await organizationRepository.ReplaceAsync(organization); @@ -260,13 +290,14 @@ public class InviteOrganizationUsersCommand(IEventService eventService, { if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { SeatsRequiredToAdd: > 0, UpdatedSeatTotal: > 0 }) { - await paymentService.AdjustSeatsAsync(organization, - validatedResult.Value.InviteOrganization.Plan, - validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal.Value); + await organizationRepository.IncrementSeatCountAsync( + organization.Id, + validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd, + validatedResult.Value.PerformedAt.UtcDateTime); - organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal; + organization.Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal; + organization.SyncSeats = true; - await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update await applicationCacheService.UpsertOrganizationAbilityAsync(organization); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs index a55db3958a..bde56a66e8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Models.Data; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs index 23c38a51cb..b0f81bd92a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs @@ -7,7 +7,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse public static class CreateOrganizationUserExtensions { - public static CreateOrganizationUser MapToDataModel(this OrganizationUserInvite organizationUserInvite, + public static CreateOrganizationUser MapToDataModel(this OrganizationUserInviteCommandModel organizationUserInvite, DateTimeOffset performedAt, InviteOrganization organization) => new() diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs index 84b350c551..2a54f26eb8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs @@ -4,12 +4,12 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse public class InviteOrganizationUsersRequest { - public OrganizationUserInvite[] Invites { get; } = []; + public OrganizationUserInviteCommandModel[] Invites { get; } = []; public InviteOrganization InviteOrganization { get; } public Guid PerformedBy { get; } public DateTimeOffset PerformedAt { get; } - public InviteOrganizationUsersRequest(OrganizationUserInvite[] invites, + public InviteOrganizationUsersRequest(OrganizationUserInviteCommandModel[] invites, InviteOrganization inviteOrganization, Guid performedBy, DateTimeOffset performedAt) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs index ac7d864dd4..5e461e7d0b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs index f45c705cab..e2eb91454c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; using Bit.Core.Models.Business; @@ -29,7 +32,7 @@ public class InviteOrganizationUsersValidationRequest SecretsManagerSubscriptionUpdate = smSubscriptionUpdate; } - public OrganizationUserInvite[] Invites { get; init; } = []; + public OrganizationUserInviteCommandModel[] Invites { get; init; } = []; public InviteOrganization InviteOrganization { get; init; } public Guid PerformedBy { get; init; } public DateTimeOffset PerformedAt { get; init; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteCommandModel.cs similarity index 88% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteCommandModel.cs index 0b83680aa5..4d0f56efe4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteCommandModel.cs @@ -7,7 +7,7 @@ using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Invite namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -public class OrganizationUserInvite +public class OrganizationUserInviteCommandModel { public string Email { get; private init; } public CollectionAccessSelection[] AssignedCollections { get; private init; } @@ -17,7 +17,7 @@ public class OrganizationUserInvite public bool AccessSecretsManager { get; private init; } public Guid[] Groups { get; private init; } - public OrganizationUserInvite(string email, string externalId) : + public OrganizationUserInviteCommandModel(string email, string externalId) : this( email: email, assignedCollections: [], @@ -29,7 +29,7 @@ public class OrganizationUserInvite { } - public OrganizationUserInvite(OrganizationUserInvite invite, bool accessSecretsManager) : + public OrganizationUserInviteCommandModel(OrganizationUserInviteCommandModel invite, bool accessSecretsManager) : this(invite.Email, invite.AssignedCollections, invite.Groups, @@ -41,7 +41,7 @@ public class OrganizationUserInvite } - public OrganizationUserInvite(string email, + public OrganizationUserInviteCommandModel(string email, IEnumerable assignedCollections, IEnumerable groups, OrganizationUserType type, diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ResendOrganizationInviteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ResendOrganizationInviteCommand.cs new file mode 100644 index 0000000000..7e68af7816 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ResendOrganizationInviteCommand.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Utilities.DebuggingInstruments; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public class ResendOrganizationInviteCommand : IResendOrganizationInviteCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly ILogger _logger; + + public ResendOrganizationInviteCommand( + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + ILogger logger) + { + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; + _logger = logger; + } + + public async Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, + bool initOrganization = false) + { + var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if (organizationUser == null || organizationUser.OrganizationId != organizationId || + organizationUser.Status != OrganizationUserStatusType.Invited) + { + throw new BadRequestException("User invalid."); + } + + _logger.LogUserInviteStateDiagnostics(organizationUser); + + var organization = await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId); + if (organization == null) + { + throw new BadRequestException("Organization invalid."); + } + await SendInviteAsync(organizationUser, organization, initOrganization); + } + + private async Task SendInviteAsync(OrganizationUser organizationUser, Organization organization, bool initOrganization) => + await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( + users: [organizationUser], + organization: organization, + initOrganization: initOrganization)); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs index ba85ce1d8a..cd5066d11b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.Repositories; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs index 54f26cb46a..f8bd988cab 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs @@ -6,7 +6,6 @@ using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; -using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; @@ -38,7 +37,7 @@ public class InviteOrganizationUsersValidator( request = new InviteOrganizationUsersValidationRequest(request) { Invites = request.Invites - .Select(x => new OrganizationUserInvite(x, accessSecretsManager: true)) + .Select(x => new OrganizationUserInviteCommandModel(x, accessSecretsManager: true)) .ToArray() }; } @@ -60,9 +59,12 @@ public class InviteOrganizationUsersValidator( { try { + var organization = await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId); + + organization!.Seats = subscriptionUpdate.UpdatedSeatTotal; var smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate( - organization: await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId), + organization: organization, plan: request.InviteOrganization.Plan, autoscaling: true); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs index f5259d1066..67155fe91a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs index dea35c4ddd..fcde0f9ebf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Enums; using Bit.Core.Models.Business; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs index 587e04826b..b6152060e8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs @@ -3,7 +3,6 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Utilities; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; @@ -14,19 +13,16 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer { private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; - private readonly IFeatureService _featureService; private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; public OrganizationUserUserDetailsQuery( IOrganizationUserRepository organizationUserRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IFeatureService featureService, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery ) { _organizationUserRepository = organizationUserRepository; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; - _featureService = featureService; _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; } @@ -43,9 +39,12 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer return organizationUsers .Select(o => { - var userPermissions = o.GetPermissions(); - - o.Permissions = CoreHelpers.ClassToJsonData(userPermissions); + // Only set permissions for Custom user types for performance optimization + if (o.Type == OrganizationUserType.Custom) + { + var userPermissions = o.GetPermissions(); + o.Permissions = CoreHelpers.ClassToJsonData(userPermissions); + } return o; }); @@ -59,12 +58,30 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer /// List of OrganizationUserUserDetails public async Task> Get(OrganizationUserUserDetailsQueryRequest request) { - var organizationUsers = await GetOrganizationUserUserDetails(request); + var organizationUsers = await _organizationUserRepository + .GetManyDetailsByOrganizationAsync_vNext(request.OrganizationId, request.IncludeGroups, request.IncludeCollections); - var organizationUsersTwoFactorEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers)).ToDictionary(u => u.user.Id); - var organizationUsersClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id)); - var responses = organizationUsers.Select(o => (o, organizationUsersTwoFactorEnabled[o.Id].twoFactorIsEnabled, organizationUsersClaimedStatus[o.Id])); + var twoFactorTask = _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); + var claimedStatusTask = _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id)); + await Task.WhenAll(twoFactorTask, claimedStatusTask); + + var organizationUsersTwoFactorEnabled = twoFactorTask.Result.ToDictionary(u => u.user.Id, u => u.twoFactorIsEnabled); + var organizationUsersClaimedStatus = claimedStatusTask.Result; + var responses = organizationUsers.Select(organizationUserDetails => + { + // Only set permissions for Custom user types for performance optimization + if (organizationUserDetails.Type == OrganizationUserType.Custom) + { + var organizationUserPermissions = organizationUserDetails.GetPermissions(); + organizationUserDetails.Permissions = CoreHelpers.ClassToJsonData(organizationUserPermissions); + } + + var userHasTwoFactorEnabled = organizationUsersTwoFactorEnabled[organizationUserDetails.Id]; + var userIsClaimedByOrganization = organizationUsersClaimedStatus[organizationUserDetails.Id]; + + return (organizationUserDetails, userHasTwoFactorEnabled, userIsClaimedByOrganization); + }); return responses; } @@ -77,15 +94,33 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer /// List of OrganizationUserUserDetails public async Task> GetAccountRecoveryEnrolledUsers(OrganizationUserUserDetailsQueryRequest request) { - var organizationUsers = (await GetOrganizationUserUserDetails(request)) - .Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && !String.IsNullOrEmpty(o.ResetPasswordKey)); + var organizationUsers = (await _organizationUserRepository + .GetManyDetailsByOrganizationAsync_vNext(request.OrganizationId, request.IncludeGroups, request.IncludeCollections)) + .Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && !String.IsNullOrEmpty(o.ResetPasswordKey)) + .ToArray(); - var organizationUsersTwoFactorEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers)).ToDictionary(u => u.user.Id); - var organizationUsersClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id)); - var responses = organizationUsers - .Select(o => (o, organizationUsersTwoFactorEnabled[o.Id].twoFactorIsEnabled, organizationUsersClaimedStatus[o.Id])); + var twoFactorTask = _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); + var claimedStatusTask = _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id)); + + await Task.WhenAll(twoFactorTask, claimedStatusTask); + + var organizationUsersTwoFactorEnabled = twoFactorTask.Result.ToDictionary(u => u.user.Id, u => u.twoFactorIsEnabled); + var organizationUsersClaimedStatus = claimedStatusTask.Result; + var responses = organizationUsers.Select(organizationUserDetails => + { + // Only set permissions for Custom user types for performance optimization + if (organizationUserDetails.Type == OrganizationUserType.Custom) + { + var organizationUserPermissions = organizationUserDetails.GetPermissions(); + organizationUserDetails.Permissions = CoreHelpers.ClassToJsonData(organizationUserPermissions); + } + + var userHasTwoFactorEnabled = organizationUsersTwoFactorEnabled[organizationUserDetails.Id]; + var userIsClaimedByOrganization = organizationUsersClaimedStatus[organizationUserDetails.Id]; + + return (organizationUserDetails, userHasTwoFactorEnabled, userIsClaimedByOrganization); + }); return responses; } - } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs index 00d3ebb533..d1eec1bc76 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 0d9955eecf..651a9225b4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..f24e0ae265 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs @@ -0,0 +1,135 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public class RevokeOrganizationUserCommand( + IEventService eventService, + IPushNotificationService pushNotificationService, + IOrganizationUserRepository organizationUserRepository, + ICurrentContext currentContext, + IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) + : IRevokeOrganizationUserCommand +{ + public async Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId) + { + if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId.Value) + { + throw new BadRequestException("You cannot revoke yourself."); + } + + if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue && + !await currentContext.OrganizationOwner(organizationUser.OrganizationId)) + { + throw new BadRequestException("Only owners can revoke other owners."); + } + + await RepositoryRevokeUserAsync(organizationUser); + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); + + if (organizationUser.UserId.HasValue) + { + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); + } + } + + public async Task RevokeUserAsync(OrganizationUser organizationUser, + EventSystemUser systemUser) + { + await RepositoryRevokeUserAsync(organizationUser); + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, + systemUser); + + if (organizationUser.UserId.HasValue) + { + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); + } + } + + private async Task RepositoryRevokeUserAsync(OrganizationUser organizationUser) + { + if (organizationUser.Status == OrganizationUserStatusType.Revoked) + { + throw new BadRequestException("Already revoked."); + } + + if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationUser.OrganizationId, + new[] { organizationUser.Id }, includeProvider: true)) + { + throw new BadRequestException("Organization must have at least one confirmed owner."); + } + + await organizationUserRepository.RevokeAsync(organizationUser.Id); + organizationUser.Status = OrganizationUserStatusType.Revoked; + } + + public async Task>> RevokeUsersAsync(Guid organizationId, + IEnumerable organizationUserIds, Guid? revokingUserId) + { + var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds); + var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) + .ToList(); + + if (!filteredUsers.Any()) + { + throw new BadRequestException("Users invalid."); + } + + if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds)) + { + throw new BadRequestException("Organization must have at least one confirmed owner."); + } + + var deletingUserIsOwner = false; + if (revokingUserId.HasValue) + { + deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId); + } + + var result = new List>(); + + foreach (var organizationUser in filteredUsers) + { + try + { + if (organizationUser.Status == OrganizationUserStatusType.Revoked) + { + throw new BadRequestException("Already revoked."); + } + + if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId) + { + throw new BadRequestException("You cannot revoke yourself."); + } + + if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue && + !deletingUserIsOwner) + { + throw new BadRequestException("Only owners can revoke other owners."); + } + + await organizationUserRepository.RevokeAsync(organizationUser.Id); + organizationUser.Status = OrganizationUserStatusType.Revoked; + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); + if (organizationUser.UserId.HasValue) + { + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); + } + + result.Add(Tuple.Create(organizationUser, "")); + } + catch (BadRequestException e) + { + result.Add(Tuple.Create(organizationUser, e.Message)); + } + } + + return result; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs index 8d1e693e8b..2623242ad6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs @@ -89,7 +89,7 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand if (collectionAccessList.Count != 0) { - await ValidateCollectionAccessAsync(originalOrganizationUser, collectionAccessList); + collectionAccessList = await ValidateAccessAndFilterDefaultUserCollectionsAsync(originalOrganizationUser, collectionAccessList); } if (groupAccess?.Any() == true) @@ -179,11 +179,19 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand throw new BadRequestException("User can only be an admin of one free organization."); } - private async Task ValidateCollectionAccessAsync(OrganizationUser originalUser, - ICollection collectionAccess) + private async Task> ValidateAccessAndFilterDefaultUserCollectionsAsync( + OrganizationUser originalUser, List collectionAccess) { var collections = await _collectionRepository .GetManyByManyIdsAsync(collectionAccess.Select(c => c.Id)); + + ValidateCollections(originalUser, collectionAccess, collections); + + return ExcludeDefaultUserCollections(collectionAccess, collections); + } + + private static void ValidateCollections(OrganizationUser originalUser, List collectionAccess, ICollection collections) + { var collectionIds = collections.Select(c => c.Id); var missingCollection = collectionAccess @@ -201,6 +209,12 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand } } + private static List ExcludeDefaultUserCollections( + List collectionAccess, ICollection collections) => + collectionAccess + .Where(cas => collections.Any(c => c.Id == cas.Id && c.Type != CollectionType.DefaultUserCollection)) + .ToList(); + private async Task ValidateGroupAccessAsync(OrganizationUser originalUser, ICollection groupAccess) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index f26061cbd2..8d8ab8cdfc 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -1,10 +1,13 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/GetOrganizationSubscriptionsToUpdateQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/GetOrganizationSubscriptionsToUpdateQuery.cs new file mode 100644 index 0000000000..faf435addd --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/GetOrganizationSubscriptionsToUpdateQuery.cs @@ -0,0 +1,24 @@ +using Bit.Core.AdminConsole.Models.Data.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Pricing; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public class GetOrganizationSubscriptionsToUpdateQuery(IOrganizationRepository organizationRepository, + IPricingClient pricingClient) : IGetOrganizationSubscriptionsToUpdateQuery +{ + public async Task> GetOrganizationSubscriptionsToUpdateAsync() + { + var organizationsToUpdateTask = organizationRepository.GetOrganizationsForSubscriptionSyncAsync(); + var plansTask = pricingClient.ListPlans(); + + await Task.WhenAll(organizationsToUpdateTask, plansTask); + + return organizationsToUpdateTask.Result.Select(o => new OrganizationSubscriptionUpdate + { + Organization = o, + Plan = plansTask.Result.FirstOrDefault(plan => plan.Type == o.PlanType) + }); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs index 3e060c66a5..6474914b48 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Entities; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IGetOrganizationSubscriptionsToUpdateQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IGetOrganizationSubscriptionsToUpdateQuery.cs new file mode 100644 index 0000000000..e45a3ba957 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IGetOrganizationSubscriptionsToUpdateQuery.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.Models.Data.Organizations; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface IGetOrganizationSubscriptionsToUpdateQuery +{ + /// + /// Retrieves a collection of organization subscriptions that need to be updated. This is based on if the + /// Organization.SyncSeats flag is true and Organization.Seats has a value. + /// + /// + /// A collection of instances, each representing an organization + /// subscription to be updated with their associated plan. + /// + Task> GetOrganizationSubscriptionsToUpdateAsync(); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/ISelfHostedOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/ISelfHostedOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..2686384a34 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/ISelfHostedOrganizationSignUpCommand.cs @@ -0,0 +1,15 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface ISelfHostedOrganizationSignUpCommand +{ + /// + /// Create a new organization on a self-hosted instance + /// + Task<(Organization organization, OrganizationUser? organizationUser)> SignUpAsync( + OrganizationLicense license, User owner, string ownerKey, + string? collectionName, string publicKey, string privateKey); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IUpdateOrganizationSubscriptionCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IUpdateOrganizationSubscriptionCommand.cs new file mode 100644 index 0000000000..c8f5a15d39 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IUpdateOrganizationSubscriptionCommand.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.Models.Data.Organizations; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface IUpdateOrganizationSubscriptionCommand +{ + /// + /// Attempts to update the subscription of all organizations that have had a subscription update. + /// + /// If successful, the Organization.SyncSeats flag will be set to false and Organization.RevisionDate will be set. + /// + /// In the event of a failure, it will log the failure and maybe be picked up in later runs. + /// + /// The collection of organization subscriptions to update. + Task UpdateOrganizationSubscriptionAsync(IEnumerable subscriptionsToUpdate); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs index aa85c7e2a4..3f26ca372c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs index c3e945b65f..27e70fbe2d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Entities; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..c52b7c10c9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs @@ -0,0 +1,216 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUpCommand +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; + private readonly IApplicationCacheService _applicationCacheService; + private readonly ICollectionRepository _collectionRepository; + private readonly IPushRegistrationService _pushRegistrationService; + private readonly IPushNotificationService _pushNotificationService; + private readonly IDeviceRepository _deviceRepository; + private readonly ILicensingService _licensingService; + private readonly IPolicyService _policyService; + private readonly IGlobalSettings _globalSettings; + private readonly IPaymentService _paymentService; + + public SelfHostedOrganizationSignUpCommand( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationApiKeyRepository organizationApiKeyRepository, + IApplicationCacheService applicationCacheService, + ICollectionRepository collectionRepository, + IPushRegistrationService pushRegistrationService, + IPushNotificationService pushNotificationService, + IDeviceRepository deviceRepository, + ILicensingService licensingService, + IPolicyService policyService, + IGlobalSettings globalSettings, + IPaymentService paymentService) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _organizationApiKeyRepository = organizationApiKeyRepository; + _applicationCacheService = applicationCacheService; + _collectionRepository = collectionRepository; + _pushRegistrationService = pushRegistrationService; + _pushNotificationService = pushNotificationService; + _deviceRepository = deviceRepository; + _licensingService = licensingService; + _policyService = policyService; + _globalSettings = globalSettings; + _paymentService = paymentService; + } + + public async Task<(Organization organization, OrganizationUser? organizationUser)> SignUpAsync( + OrganizationLicense license, User owner, string ownerKey, string? collectionName, string publicKey, + string privateKey) + { + if (license.LicenseType != LicenseType.Organization) + { + throw new BadRequestException("Premium licenses cannot be applied to an organization. " + + "Upload this license from your personal account settings page."); + } + + var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); + var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception); + + if (!canUse) + { + throw new BadRequestException(exception); + } + + var enabledOrgs = await _organizationRepository.GetManyByEnabledAsync(); + if (enabledOrgs.Any(o => string.Equals(o.LicenseKey, license.LicenseKey))) + { + throw new BadRequestException("License is already in use by another organization."); + } + + await ValidateSignUpPoliciesAsync(owner.Id); + + var organization = claimsPrincipal != null + // If the ClaimsPrincipal exists (there's a token on the license), use it to build the organization. + ? OrganizationFactory.Create(owner, claimsPrincipal, publicKey, privateKey) + // If there's no ClaimsPrincipal (there's no token on the license), use the license to build the organization. + : OrganizationFactory.Create(owner, license, publicKey, privateKey); + + var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); + + var dir = $"{_globalSettings.LicenseDirectory}/organization"; + Directory.CreateDirectory(dir); + await using var fs = new FileStream(Path.Combine(dir, $"{organization.Id}.json"), FileMode.Create); + await JsonSerializer.SerializeAsync(fs, license, JsonHelpers.Indented); + return (result.organization, result.organizationUser); + } + + private async Task ValidateSignUpPoliciesAsync(Guid ownerId) + { + var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); + if (anySingleOrgPolicies) + { + throw new BadRequestException("You may not create an organization. You belong to an organization " + + "which has a policy that prohibits you from being a member of any other organization."); + } + } + + /// + /// Private helper method to create a new organization. + /// This is common code used by both the cloud and self-hosted methods. + /// + private async Task<(Organization organization, OrganizationUser? organizationUser, Collection? defaultCollection)> + SignUpAsync(Organization organization, + Guid ownerId, string ownerKey, string? collectionName, bool withPayment) + { + try + { + await _organizationRepository.CreateAsync(organization); + await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey + { + OrganizationId = organization.Id, + ApiKey = CoreHelpers.SecureRandomString(30), + Type = OrganizationApiKeyType.Default, + RevisionDate = DateTime.UtcNow, + }); + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + // ownerId == default if the org is created by a provider - in this case it's created without an + // owner and the first owner is immediately invited afterwards + OrganizationUser? orgUser = null; + if (ownerId != default) + { + orgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = ownerId, + Key = ownerKey, + AccessSecretsManager = organization.UseSecretsManager, + Type = OrganizationUserType.Owner, + Status = OrganizationUserStatusType.Confirmed, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + orgUser.SetNewId(); + + await _organizationUserRepository.CreateAsync(orgUser); + + var devices = await GetUserDeviceIdsAsync(orgUser.UserId!.Value); + await _pushRegistrationService.AddUserRegistrationOrganizationAsync(devices, + organization.Id.ToString()); + await _pushNotificationService.PushSyncOrgKeysAsync(ownerId); + } + + Collection? defaultCollection = null; + if (!string.IsNullOrWhiteSpace(collectionName)) + { + defaultCollection = new Collection + { + Name = collectionName, + OrganizationId = organization.Id, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + + // Give the owner Can Manage access over the default collection + List? defaultOwnerAccess = null; + if (orgUser != null) + { + defaultOwnerAccess = + [ + new CollectionAccessSelection + { + Id = orgUser.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + } + ]; + } + + await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); + } + + return (organization, orgUser, defaultCollection); + } + catch + { + if (withPayment) + { + await _paymentService.CancelAndRecoverChargesAsync(organization); + } + + if (organization.Id != default(Guid)) + { + await _organizationRepository.DeleteAsync(organization); + await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); + } + + throw; + } + } + + private async Task> GetUserDeviceIdsAsync(Guid userId) + { + var devices = await _deviceRepository.GetManyByUserIdAsync(userId); + return devices + .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) + .Select(d => d.Id.ToString()); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs new file mode 100644 index 0000000000..450f425bdf --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs @@ -0,0 +1,43 @@ +using Bit.Core.AdminConsole.Models.Data.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public class UpdateOrganizationSubscriptionCommand(IPaymentService paymentService, + IOrganizationRepository repository, + TimeProvider timeProvider, + ILogger logger) : IUpdateOrganizationSubscriptionCommand +{ + public async Task UpdateOrganizationSubscriptionAsync(IEnumerable subscriptionsToUpdate) + { + var successfulSyncs = new List(); + + foreach (var subscriptionUpdate in subscriptionsToUpdate) + { + try + { + await paymentService.AdjustSeatsAsync(subscriptionUpdate.Organization, + subscriptionUpdate.Plan, + subscriptionUpdate.Seats); + + successfulSyncs.Add(subscriptionUpdate.Organization.Id); + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to update organization {organizationId} subscription.", + subscriptionUpdate.Organization.Id); + } + } + + if (successfulSyncs.Count == 0) + { + return; + } + + await repository.UpdateSuccessfulOrganizationSyncStatusAsync(successfulSyncs, timeProvider.GetUtcNow().UtcDateTime); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyRequirementQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyRequirementQuery.cs index 5736078f22..e662716142 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyRequirementQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyRequirementQuery.cs @@ -15,4 +15,13 @@ public interface IPolicyRequirementQuery /// The user that you need to enforce the policy against. /// The IPolicyRequirement that corresponds to the policy you want to enforce. Task GetAsync(Guid userId) where T : IPolicyRequirement; + + /// + /// Get all organization user IDs within an organization that are affected by a given policy type. + /// Respects role/status/provider exemptions via the policy factory's Enforce predicate. + /// + /// The organization to check. + /// The IPolicyRequirement that corresponds to the policy type to evaluate. + /// Organization user IDs for whom the policy applies within the organization. + Task> GetManyByOrganizationIdAsync(Guid organizationId) where T : IPolicyRequirement; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPostSavePolicySideEffect.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPostSavePolicySideEffect.cs new file mode 100644 index 0000000000..e90945d12d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPostSavePolicySideEffect.cs @@ -0,0 +1,10 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; + +public interface IPostSavePolicySideEffect +{ + public Task ExecuteSideEffectsAsync(SavePolicyModel policyRequest, Policy postUpdatedPolicy, + Policy? previousPolicyState); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs index 6ca842686e..73278d77d2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs @@ -6,4 +6,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; public interface ISavePolicyCommand { Task SaveAsync(PolicyUpdate policy); + + /// + /// FIXME: this is a first pass at implementing side effects after the policy has been saved, which was not supported by the validator pattern. + /// However, this needs to be implemented in a policy-agnostic way rather than building out switch statements in the command itself. + /// + Task VNextSaveAsync(SavePolicyModel policyRequest); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs index de4796d4b5..e846e02e46 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs @@ -1,5 +1,6 @@ #nullable enable +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; @@ -27,6 +28,29 @@ public class PolicyRequirementQuery( return requirement; } + public async Task> GetManyByOrganizationIdAsync(Guid organizationId) + where T : IPolicyRequirement + { + var factory = factories.OfType>().SingleOrDefault(); + if (factory is null) + { + throw new NotImplementedException("No Requirement Factory found for " + typeof(T)); + } + + var organizationPolicyDetails = await GetOrganizationPolicyDetails(organizationId, factory.PolicyType); + + var eligibleOrganizationUserIds = organizationPolicyDetails + .Where(p => p.PolicyType == factory.PolicyType) + .Where(factory.Enforce) + .Select(p => p.OrganizationUserId) + .ToList(); + + return eligibleOrganizationUserIds; + } + private Task> GetPolicyDetails(Guid userId) => policyRepository.GetPolicyDetailsByUserId(userId); + + private async Task> GetOrganizationPolicyDetails(Guid organizationId, PolicyType policyType) + => await policyRepository.GetPolicyDetailsByOrganizationIdAsync(organizationId, policyType); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs index 71212aaf4c..e2bca930d1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.Repositories; @@ -17,18 +15,20 @@ public class SavePolicyCommand : ISavePolicyCommand private readonly IPolicyRepository _policyRepository; private readonly IReadOnlyDictionary _policyValidators; private readonly TimeProvider _timeProvider; + private readonly IPostSavePolicySideEffect _postSavePolicySideEffect; - public SavePolicyCommand( - IApplicationCacheService applicationCacheService, + public SavePolicyCommand(IApplicationCacheService applicationCacheService, IEventService eventService, IPolicyRepository policyRepository, IEnumerable policyValidators, - TimeProvider timeProvider) + TimeProvider timeProvider, + IPostSavePolicySideEffect postSavePolicySideEffect) { _applicationCacheService = applicationCacheService; _eventService = eventService; _policyRepository = policyRepository; _timeProvider = timeProvider; + _postSavePolicySideEffect = postSavePolicySideEffect; var policyValidatorsDict = new Dictionary(); foreach (var policyValidator in policyValidators) @@ -78,12 +78,28 @@ public class SavePolicyCommand : ISavePolicyCommand return policy; } + public async Task VNextSaveAsync(SavePolicyModel policyRequest) + { + var (_, currentPolicy) = await GetCurrentPolicyStateAsync(policyRequest.PolicyUpdate); + + var policy = await SaveAsync(policyRequest.PolicyUpdate); + + await ExecutePostPolicySaveSideEffectsForSupportedPoliciesAsync(policyRequest, policy, currentPolicy); + + return policy; + } + + private async Task ExecutePostPolicySaveSideEffectsForSupportedPoliciesAsync(SavePolicyModel policyRequest, Policy postUpdatedPolicy, Policy? previousPolicyState) + { + if (postUpdatedPolicy.Type == PolicyType.OrganizationDataOwnership) + { + await _postSavePolicySideEffect.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); + } + } + 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); + var (savedPoliciesDict, currentPolicy) = await GetCurrentPolicyStateAsync(policyUpdate); // If enabling this policy - check that all policy requirements are satisfied if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled) @@ -127,4 +143,13 @@ public class SavePolicyCommand : ISavePolicyCommand // Run side effects await validator.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); } + + private async Task<(Dictionary savedPoliciesDict, Policy? currentPolicy)> GetCurrentPolicyStateAsync(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); + return (savedPoliciesDict, currentPolicy); + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/EmptyMetadataModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/EmptyMetadataModel.cs new file mode 100644 index 0000000000..0c086ac575 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/EmptyMetadataModel.cs @@ -0,0 +1,6 @@ + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +public record EmptyMetadataModel : IPolicyMetadataModel +{ +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/IPolicyMetadataModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/IPolicyMetadataModel.cs new file mode 100644 index 0000000000..5331524a1d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/IPolicyMetadataModel.cs @@ -0,0 +1,6 @@ + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +public interface IPolicyMetadataModel +{ +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/OrganizationModelOwnershipPolicyModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/OrganizationModelOwnershipPolicyModel.cs new file mode 100644 index 0000000000..0ff9200d8f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/OrganizationModelOwnershipPolicyModel.cs @@ -0,0 +1,16 @@ + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +public class OrganizationModelOwnershipPolicyModel : IPolicyMetadataModel +{ + public OrganizationModelOwnershipPolicyModel() + { + } + + public OrganizationModelOwnershipPolicyModel(string? defaultUserCollectionName) + { + DefaultUserCollectionName = defaultUserCollectionName; + } + + public string? DefaultUserCollectionName { get; set; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs new file mode 100644 index 0000000000..7c8d5126e8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs @@ -0,0 +1,8 @@ + +using Bit.Core.AdminConsole.Models.Data; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +public record SavePolicyModel(PolicyUpdate PolicyUpdate, IActingUser? PerformedBy, IPolicyMetadataModel Metadata) +{ +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/MasterPasswordPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/MasterPasswordPolicyRequirement.cs new file mode 100644 index 0000000000..9e8154db53 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/MasterPasswordPolicyRequirement.cs @@ -0,0 +1,55 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +/// +/// Policy requirements for the Master Password Requirements policy. +/// +public class MasterPasswordPolicyRequirement : IPolicyRequirement +{ + /// + /// Indicates whether MasterPassword requirements are enabled for the user. + /// + public bool Enabled { get; init; } + + /// + /// Master Password Policy data model associated with this Policy + /// + public MasterPasswordPolicyData? EnforcedOptions { get; init; } +} + +public class MasterPasswordPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.MasterPassword; + + protected override bool ExemptProviders => false; + + protected override IEnumerable ExemptRoles => []; + + protected override IEnumerable ExemptStatuses => + [OrganizationUserStatusType.Accepted, + OrganizationUserStatusType.Invited, + OrganizationUserStatusType.Revoked, + ]; + + public override MasterPasswordPolicyRequirement Create(IEnumerable policyDetails) + { + var result = policyDetails + .Select(p => p.GetDataModel()) + .Aggregate( + new MasterPasswordPolicyRequirement(), + (result, data) => + { + data.CombineWith(result.EnforcedOptions); + return new MasterPasswordPolicyRequirement + { + Enabled = true, + EnforcedOptions = data + }; + }); + + return result; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs index 7ccb3f7807..28d6614dcb 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -24,19 +25,19 @@ public enum OrganizationDataOwnershipState /// public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement { - private readonly IEnumerable _organizationIdsWithPolicyEnabled; + private readonly IEnumerable _policyDetails; /// /// The organization data ownership state for the user. /// - /// - /// The collection of Organization IDs that have the Organization Data Ownership policy enabled. + /// + /// An enumerable collection of PolicyDetails for the organizations. /// public OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState organizationDataOwnershipState, - IEnumerable organizationIdsWithPolicyEnabled) + IEnumerable policyDetails) { - _organizationIdsWithPolicyEnabled = organizationIdsWithPolicyEnabled ?? []; + _policyDetails = policyDetails; State = organizationDataOwnershipState; } @@ -46,12 +47,37 @@ public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement public OrganizationDataOwnershipState State { get; } /// - /// Returns true if the Organization Data Ownership policy is enforced in that organization. + /// Gets a default collection request for enforcing the Organization Data Ownership policy. + /// Only confirmed users are applicable. + /// This indicates whether the user should have a default collection created for them when the policy is enabled, + /// and if so, the relevant OrganizationUserId to create the collection for. /// - public bool RequiresDefaultCollection(Guid organizationId) + /// The organization ID to create the request for. + /// A DefaultCollectionRequest containing the OrganizationUserId and a flag indicating whether to create a default collection. + public DefaultCollectionRequest GetDefaultCollectionRequestOnPolicyEnable(Guid organizationId) { - return _organizationIdsWithPolicyEnabled.Contains(organizationId); + var policyDetail = _policyDetails + .FirstOrDefault(p => p.OrganizationId == organizationId); + + if (policyDetail != null && policyDetail.HasStatus([OrganizationUserStatusType.Confirmed])) + { + return new DefaultCollectionRequest(policyDetail.OrganizationUserId, true); + } + + var noCollectionNeeded = new DefaultCollectionRequest(Guid.Empty, false); + return noCollectionNeeded; } + + public bool RequiresDefaultCollectionOnConfirm(Guid organizationId) + { + return _policyDetails.Any(p => p.OrganizationId == organizationId); + } +} + +public record DefaultCollectionRequest(Guid OrganizationUserId, bool ShouldCreateDefaultCollection) +{ + public readonly bool ShouldCreateDefaultCollection = ShouldCreateDefaultCollection; + public readonly Guid OrganizationUserId = OrganizationUserId; } public class OrganizationDataOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory @@ -63,10 +89,9 @@ public class OrganizationDataOwnershipPolicyRequirementFactory : BasePolicyRequi var organizationDataOwnershipState = policyDetails.Any() ? OrganizationDataOwnershipState.Enabled : OrganizationDataOwnershipState.Disabled; - var organizationIdsWithPolicyEnabled = policyDetails.Select(p => p.OrganizationId).ToHashSet(); return new OrganizationDataOwnershipPolicyRequirement( organizationDataOwnershipState, - organizationIdsWithPolicyEnabled); + policyDetails); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs index b7d0b14f15..1d703fa4d4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 87fdcbe543..5433d70410 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static class PolicyServiceCollectionExtensions services.AddPolicyValidators(); services.AddPolicyRequirements(); + services.AddPolicySideEffects(); } private static void AddPolicyValidators(this IServiceCollection services) @@ -29,6 +30,11 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); } + private static void AddPolicySideEffects(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddPolicyRequirements(this IServiceCollection services) { services.AddScoped, DisableSendPolicyRequirementFactory>(); @@ -37,5 +43,6 @@ public static class PolicyServiceCollectionExtensions services.AddScoped, OrganizationDataOwnershipPolicyRequirementFactory>(); services.AddScoped, RequireSsoPolicyRequirementFactory>(); services.AddScoped, RequireTwoFactorPolicyRequirementFactory>(); + services.AddScoped, MasterPasswordPolicyRequirementFactory>(); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs new file mode 100644 index 0000000000..f4ef6021a7 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs @@ -0,0 +1,72 @@ + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +/// +/// Please do not extend or expand this validator. We're currently in the process of refactoring our policy validator pattern. +/// This is a stop-gap solution for post-policy-save side effects, but it is not the long-term solution. +/// +public class OrganizationDataOwnershipPolicyValidator( + IPolicyRepository policyRepository, + ICollectionRepository collectionRepository, + IEnumerable> factories, + IFeatureService featureService) + : OrganizationPolicyValidator(policyRepository, factories), IPostSavePolicySideEffect +{ + public async Task ExecuteSideEffectsAsync( + SavePolicyModel policyRequest, + Policy postUpdatedPolicy, + Policy? previousPolicyState) + { + if (!featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) + { + return; + } + + if (policyRequest.Metadata is not OrganizationModelOwnershipPolicyModel metadata) + { + return; + } + + if (string.IsNullOrWhiteSpace(metadata.DefaultUserCollectionName)) + { + return; + } + + var isFirstTimeEnabled = postUpdatedPolicy.Enabled && previousPolicyState == null; + var reEnabled = previousPolicyState?.Enabled == false + && postUpdatedPolicy.Enabled; + + if (isFirstTimeEnabled || reEnabled) + { + await UpsertDefaultCollectionsForUsersAsync(policyRequest.PolicyUpdate, metadata.DefaultUserCollectionName); + } + } + + private async Task UpsertDefaultCollectionsForUsersAsync(PolicyUpdate policyUpdate, string defaultCollectionName) + { + var requirements = await GetUserPolicyRequirementsByOrganizationIdAsync(policyUpdate.OrganizationId, policyUpdate.Type); + + var userOrgIds = requirements + .Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId)) + .Where(request => request.ShouldCreateDefaultCollection) + .Select(request => request.OrganizationUserId); + + if (!userOrgIds.Any()) + { + return; + } + + await collectionRepository.UpsertDefaultCollectionsAsync( + policyUpdate.OrganizationId, + userOrgIds, + defaultCollectionName); + } + +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs new file mode 100644 index 0000000000..15a0b4bb54 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs @@ -0,0 +1,38 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + + +/// +/// Please do not use this validator. We're currently in the process of refactoring our policy validator pattern. +/// This is a stop-gap solution for post-policy-save side effects, but it is not the long-term solution. +/// +public abstract class OrganizationPolicyValidator(IPolicyRepository policyRepository, IEnumerable> factories) +{ + protected async Task> GetUserPolicyRequirementsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) where T : IPolicyRequirement + { + var factory = factories.OfType>().SingleOrDefault(); + if (factory is null) + { + throw new NotImplementedException("No Requirement Factory found for " + typeof(T)); + } + + var policyDetails = await policyRepository.GetPolicyDetailsByOrganizationIdAsync(organizationId, policyType); + var policyDetailGroups = policyDetails.GroupBy(policyDetail => policyDetail.UserId); + var requirements = new List(); + + foreach (var policyDetailGroup in policyDetailGroups) + { + var filteredPolicies = policyDetailGroup + .Where(factory.Enforce) + // Prevent deferred execution from causing inconsistent tests. + .ToList(); + + requirements.Add(factory.Create(filteredPolicies)); + } + + return requirements; + } +} diff --git a/src/Core/AdminConsole/Repositories/IEventRepository.cs b/src/Core/AdminConsole/Repositories/IEventRepository.cs index e39ad33d18..f0c185561b 100644 --- a/src/Core/AdminConsole/Repositories/IEventRepository.cs +++ b/src/Core/AdminConsole/Repositories/IEventRepository.cs @@ -1,4 +1,5 @@ using Bit.Core.Models.Data; +using Bit.Core.SecretsManager.Entities; using Bit.Core.Vault.Entities; #nullable enable @@ -11,6 +12,13 @@ public interface IEventRepository PageOptions pageOptions); Task> GetManyByOrganizationAsync(Guid organizationId, DateTime startDate, DateTime endDate, PageOptions pageOptions); + + Task> GetManyBySecretAsync(Secret secret, DateTime startDate, DateTime endDate, + PageOptions pageOptions); + + Task> GetManyByProjectAsync(Project project, DateTime startDate, DateTime endDate, + PageOptions pageOptions); + Task> GetManyByOrganizationActingUserAsync(Guid organizationId, Guid actingUserId, DateTime startDate, DateTime endDate, PageOptions pageOptions); Task> GetManyByProviderAsync(Guid providerId, DateTime startDate, DateTime endDate, @@ -19,6 +27,7 @@ public interface IEventRepository DateTime startDate, DateTime endDate, PageOptions pageOptions); Task> GetManyByCipherAsync(Cipher cipher, DateTime startDate, DateTime endDate, PageOptions pageOptions); + Task CreateAsync(IEvent e); Task CreateManyAsync(IEnumerable e); Task> GetManyByOrganizationServiceAccountAsync(Guid organizationId, Guid serviceAccountId, diff --git a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs index 516918fff9..0a774cf395 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs @@ -10,4 +10,8 @@ public interface IOrganizationIntegrationConfigurationRepository : IRepository> GetAllConfigurationDetailsAsync(); + + Task> GetManyByIntegrationAsync(Guid organizationIntegrationId); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs index cd7700c310..434c8ddee3 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs @@ -4,4 +4,5 @@ namespace Bit.Core.Repositories; public interface IOrganizationIntegrationRepository : IRepository { + Task> GetManyByOrganizationAsync(Guid organizationId); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index 7fff0d437f..da7a77000b 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -24,6 +24,7 @@ public interface IOrganizationRepository : IRepository /// Gets the organizations that have a verified domain matching the user's email domain. ///
Task> GetByVerifiedUserEmailDomainAsync(Guid userId); + Task> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType); Task> GetManyByIdsAsync(IEnumerable ids); @@ -36,4 +37,29 @@ public interface IOrganizationRepository : IRepository /// The ID of the organization to get the occupied seat count for. /// The number of occupied seats for the organization. Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId); + + /// + /// Get all organizations that need to have their seat count updated to their Stripe subscription. + /// + /// Organizations to sync to Stripe + Task> GetOrganizationsForSubscriptionSyncAsync(); + + /// + /// Updates the organization SeatSync property to signify the organization's subscription has been updated in stripe + /// to match the password manager seats for the organization. + /// + /// + /// + /// + Task UpdateSuccessfulOrganizationSyncStatusAsync(IEnumerable successfulOrganizations, DateTime syncDate); + + /// + /// This increments the password manager seat count on the organization by the provided amount and sets SyncSeats to true. + /// It also sets the revision date using the request date. + /// + /// Organization to update + /// Amount to increase password manager seats by + /// When the action was performed + /// + Task IncrementSeatCountAsync(Guid organizationId, int increaseAmount, DateTime requestDate); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 6e07bd9ff8..37a830c92e 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -22,8 +22,26 @@ public interface IOrganizationUserRepository : IRepository GetByOrganizationAsync(Guid organizationId, Guid userId); Task>> GetByIdWithCollectionsAsync(Guid id); Task GetDetailsByIdAsync(Guid id); + /// + /// Returns the OrganizationUser and its associated collections (excluding DefaultUserCollections). + /// + /// The id of the OrganizationUser + /// A tuple containing the OrganizationUser and its associated collections Task<(OrganizationUserUserDetails? OrganizationUser, ICollection Collections)> GetDetailsByIdWithCollectionsAsync(Guid id); + /// + /// Returns the OrganizationUsers and their associated collections (excluding DefaultUserCollections). + /// + /// The id of the organization + /// Whether to include groups + /// Whether to include collections + /// A list of OrganizationUserUserDetails Task> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false); + /// + /// + /// This method is optimized for performance. + /// Reduces database round trips by fetching all data in fewer queries. + /// + Task> GetManyDetailsByOrganizationAsync_vNext(Guid organizationId, bool includeGroups = false, bool includeCollections = false); Task> GetManyDetailsByUserAsync(Guid userId, OrganizationUserStatusType? status = null); Task GetDetailsByUserAsync(Guid userId, Guid organizationId, @@ -58,7 +76,6 @@ public interface IOrganizationUserRepository : IRepository Task> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId); - Task RevokeManyByIdAsync(IEnumerable organizationUserIds); /// diff --git a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs index 4c0c03536d..9f5c7f3fc4 100644 --- a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs +++ b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs @@ -31,4 +31,28 @@ public interface IPolicyRepository : IRepository /// You probably do not want to call it directly. /// Task> GetPolicyDetailsByUserId(Guid userId); + + /// + /// Retrieves of the specified + /// for users in the given organization and for any other organizations those users belong to. + /// + /// + /// Each PolicyDetail represents an OrganizationUser and a Policy which *may* be enforced + /// against them. It only returns PolicyDetails for policies that are enabled and where the organization's plan + /// supports policies. It also excludes "revoked invited" users who are not subject to policy enforcement. + /// This is consumed by to create requirements for specific policy types. + /// You probably do not want to call it directly. + /// + Task> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType); + + /// + /// Retrieves policy details for a list of users filtered by the specified policy type. + /// + /// A collection of user identifiers for which the policy details are to be fetched. + /// The type of policy for which the details are required. + /// + /// An asynchronous task that returns a collection of objects containing the policy information + /// associated with the specified users and policy type. + /// + Task> GetPolicyDetailsByUserIdsAndPolicyType(IEnumerable userIds, PolicyType policyType); } diff --git a/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs b/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs index 81879ef931..169b36bf69 100644 --- a/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs +++ b/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs @@ -1,5 +1,6 @@ using Azure.Data.Tables; using Bit.Core.Models.Data; +using Bit.Core.SecretsManager.Entities; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; @@ -34,6 +35,20 @@ public class EventRepository : IEventRepository return await GetManyAsync($"OrganizationId={organizationId}", "Date={0}", startDate, endDate, pageOptions); } + public async Task> GetManyBySecretAsync(Secret secret, + DateTime startDate, DateTime endDate, PageOptions pageOptions) + { + return await GetManyAsync($"OrganizationId={secret.OrganizationId}", + $"SecretId={secret.Id}__Date={{0}}", startDate, endDate, pageOptions); + } + + public async Task> GetManyByProjectAsync(Project project, + DateTime startDate, DateTime endDate, PageOptions pageOptions) + { + return await GetManyAsync($"OrganizationId={project.OrganizationId}", + $"ProjectId={project.Id}__Date={{0}}", startDate, endDate, pageOptions); + } + public async Task> GetManyByOrganizationActingUserAsync(Guid organizationId, Guid actingUserId, DateTime startDate, DateTime endDate, PageOptions pageOptions) { @@ -62,12 +77,18 @@ public class EventRepository : IEventRepository return await GetManyAsync(partitionKey, $"CipherId={cipher.Id}__Date={{0}}", startDate, endDate, pageOptions); } - public async Task> GetManyByOrganizationServiceAccountAsync(Guid organizationId, - Guid serviceAccountId, DateTime startDate, DateTime endDate, PageOptions pageOptions) + public async Task> GetManyByOrganizationServiceAccountAsync( + Guid organizationId, + Guid serviceAccountId, + DateTime startDate, + DateTime endDate, + PageOptions pageOptions) { + return await GetManyServiceAccountAsync( + $"OrganizationId={organizationId}", + serviceAccountId.ToString(), + startDate, endDate, pageOptions); - return await GetManyAsync($"OrganizationId={organizationId}", - $"ServiceAccountId={serviceAccountId}__Date={{0}}", startDate, endDate, pageOptions); } public async Task CreateAsync(IEvent e) @@ -126,6 +147,40 @@ public class EventRepository : IEventRepository } } + public async Task> GetManyServiceAccountAsync( + string partitionKey, + string serviceAccountId, + DateTime startDate, + DateTime endDate, + PageOptions pageOptions) + { + var start = CoreHelpers.DateTimeToTableStorageKey(startDate); + var end = CoreHelpers.DateTimeToTableStorageKey(endDate); + var filter = MakeFilterForServiceAccount(partitionKey, serviceAccountId, startDate, endDate); + + var result = new PagedResult(); + var query = _tableClient.QueryAsync(filter, pageOptions.PageSize); + + await using (var enumerator = query.AsPages(pageOptions.ContinuationToken, + pageOptions.PageSize).GetAsyncEnumerator()) + { + if (await enumerator.MoveNextAsync()) + { + result.ContinuationToken = enumerator.Current.ContinuationToken; + + var events = enumerator.Current.Values + .Select(e => e.ToEventTableEntity()) + .ToList(); + + events = events.OrderByDescending(e => e.Date).ToList(); + + result.Data.AddRange(events); + } + } + + return result; + } + public async Task> GetManyAsync(string partitionKey, string rowKey, DateTime startDate, DateTime endDate, PageOptions pageOptions) { @@ -157,4 +212,27 @@ public class EventRepository : IEventRepository { return $"PartitionKey eq '{partitionKey}' and RowKey le '{rowStart}' and RowKey ge '{rowEnd}'"; } + + private string MakeFilterForServiceAccount( + string partitionKey, + string machineAccountId, + DateTime startDate, + DateTime endDate) + { + var start = CoreHelpers.DateTimeToTableStorageKey(startDate); + var end = CoreHelpers.DateTimeToTableStorageKey(endDate); + + var rowKey1Start = $"ServiceAccountId={machineAccountId}__Date={start}"; + var rowKey1End = $"ServiceAccountId={machineAccountId}__Date={end}"; + + var rowKey2Start = $"GrantedServiceAccountId={machineAccountId}__Date={start}"; + var rowKey2End = $"GrantedServiceAccountId={machineAccountId}__Date={end}"; + + var left = $"PartitionKey eq '{partitionKey}' and RowKey le '{rowKey1Start}' and RowKey ge '{rowKey1End}'"; + var right = $"PartitionKey eq '{partitionKey}' and RowKey le '{rowKey2Start}' and RowKey ge '{rowKey2End}'"; + + return $"({left}) or ({right})"; + } + + } diff --git a/src/Core/AdminConsole/Services/EventLoggingListenerService.cs b/src/Core/AdminConsole/Services/EventLoggingListenerService.cs index ec2db121db..84a862ce94 100644 --- a/src/Core/AdminConsole/Services/EventLoggingListenerService.cs +++ b/src/Core/AdminConsole/Services/EventLoggingListenerService.cs @@ -10,9 +10,9 @@ namespace Bit.Core.Services; public abstract class EventLoggingListenerService : BackgroundService { protected readonly IEventMessageHandler _handler; - protected ILogger _logger; + protected ILogger _logger; - protected EventLoggingListenerService(IEventMessageHandler handler, ILogger logger) + protected EventLoggingListenerService(IEventMessageHandler handler, ILogger logger) { _handler = handler; _logger = logger; @@ -28,12 +28,12 @@ public abstract class EventLoggingListenerService : BackgroundService if (root.ValueKind == JsonValueKind.Array) { var eventMessages = root.Deserialize>(); - await _handler.HandleManyEventsAsync(eventMessages); + await _handler.HandleManyEventsAsync(eventMessages ?? throw new JsonException("Deserialize returned null")); } else if (root.ValueKind == JsonValueKind.Object) { var eventMessage = root.Deserialize(); - await _handler.HandleEventAsync(eventMessage); + await _handler.HandleEventAsync(eventMessage ?? throw new JsonException("Deserialize returned null")); } else { diff --git a/src/Core/AdminConsole/Services/IEventService.cs b/src/Core/AdminConsole/Services/IEventService.cs index 5b4f8731a2..795c06e254 100644 --- a/src/Core/AdminConsole/Services/IEventService.cs +++ b/src/Core/AdminConsole/Services/IEventService.cs @@ -1,6 +1,10 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Interfaces; +using Bit.Core.Auth.Identity; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; @@ -30,6 +34,11 @@ public interface IEventService Task LogProviderOrganizationEventsAsync(IEnumerable<(ProviderOrganization, EventType, DateTime?)> events); Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, DateTime? date = null); Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, EventSystemUser systemUser, DateTime? date = null); - Task LogServiceAccountSecretEventAsync(Guid serviceAccountId, Secret secret, EventType type, DateTime? date = null); + Task LogUserSecretsEventAsync(Guid userId, IEnumerable secrets, EventType type, DateTime? date = null); Task LogServiceAccountSecretsEventAsync(Guid serviceAccountId, IEnumerable secrets, EventType type, DateTime? date = null); + Task LogUserProjectsEventAsync(Guid userId, IEnumerable projects, EventType type, DateTime? date = null); + Task LogServiceAccountProjectsEventAsync(Guid serviceAccountId, IEnumerable projects, EventType type, DateTime? date = null); + Task LogServiceAccountPeopleEventAsync(Guid userId, UserServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null); + Task LogServiceAccountGroupEventAsync(Guid userId, GroupServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null); + Task LogServiceAccountEventAsync(Guid userId, List serviceAccount, EventType type, IdentityClientType identityClientType, DateTime? date = null); } diff --git a/src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs b/src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs new file mode 100644 index 0000000000..ad27429112 --- /dev/null +++ b/src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs @@ -0,0 +1,14 @@ +#nullable enable + +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.Services; + +public interface IIntegrationConfigurationDetailsCache +{ + List GetConfigurationDetails( + Guid organizationId, + IntegrationType integrationType, + EventType eventType); +} diff --git a/src/Core/AdminConsole/Services/IIntegrationHandler.cs b/src/Core/AdminConsole/Services/IIntegrationHandler.cs index e02f26a873..bb10dc01b9 100644 --- a/src/Core/AdminConsole/Services/IIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/IIntegrationHandler.cs @@ -1,4 +1,6 @@ -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using System.Globalization; +using System.Net; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; namespace Bit.Core.Services; @@ -17,8 +19,56 @@ public abstract class IntegrationHandlerBase : IIntegrationHandler public async Task HandleAsync(string json) { var message = IntegrationMessage.FromJson(json); - return await HandleAsync(message); + return await HandleAsync(message ?? throw new ArgumentException("IntegrationMessage was null when created from the provided JSON")); } public abstract Task HandleAsync(IntegrationMessage message); + + protected IntegrationHandlerResult ResultFromHttpResponse( + HttpResponseMessage response, + IntegrationMessage message, + TimeProvider timeProvider) + { + var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message); + + if (response.IsSuccessStatusCode) return result; + + switch (response.StatusCode) + { + case HttpStatusCode.TooManyRequests: + case HttpStatusCode.RequestTimeout: + case HttpStatusCode.InternalServerError: + case HttpStatusCode.BadGateway: + case HttpStatusCode.ServiceUnavailable: + case HttpStatusCode.GatewayTimeout: + result.Retryable = true; + result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}"; + + if (response.Headers.TryGetValues("Retry-After", out var values)) + { + var value = values.FirstOrDefault(); + if (int.TryParse(value, out var seconds)) + { + // Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds. + result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; + } + else if (DateTimeOffset.TryParseExact(value, + "r", // "r" is the round-trip format: RFC1123 + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var retryDate)) + { + // Retry-after was specified as a date. Adjust DelayUntilDate to the specified date. + result.DelayUntilDate = retryDate.UtcDateTime; + } + } + break; + default: + result.Retryable = false; + result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; + break; + } + + return result; + } } diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index feae561a19..f509ac8358 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Auth.Enums; using Bit.Core.Entities; @@ -10,20 +13,14 @@ namespace Bit.Core.Services; public interface IOrganizationService { - Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null); Task ReinstateSubscriptionAsync(Guid organizationId); Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb); Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats); Task AutoAddSeatsAsync(Organization organization, int seatsToAdd); Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment); - Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); - /// - /// Create a new organization on a self-hosted instance - /// - Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync(OrganizationLicense license, User owner, - string ownerKey, string collectionName, string publicKey, string privateKey); Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate); - Task UpdateAsync(Organization organization, bool updateBilling = false, EventType eventType = EventType.Organization_Updated); + Task UpdateAsync(Organization organization, bool updateBilling = false); + Task UpdateCollectionManagementSettingsAsync(Guid organizationId, OrganizationCollectionManagementSettings settings); Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); Task DisableTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, @@ -31,16 +28,8 @@ public interface IOrganizationService Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites); Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); - Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId); - Task ImportAsync(Guid organizationId, IEnumerable groups, - IEnumerable newUsers, IEnumerable removeUserExternalIds, - bool overwriteExisting, EventSystemUser eventSystemUser); Task DeleteSsoUserAsync(Guid userId, Guid? organizationId); - Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId); - Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); - Task>> RevokeUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? revokingUserId); Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd); diff --git a/src/Core/AdminConsole/Services/IProviderService.cs b/src/Core/AdminConsole/Services/IProviderService.cs index e4b6f3aabd..2b954346ae 100644 --- a/src/Core/AdminConsole/Services/IProviderService.cs +++ b/src/Core/AdminConsole/Services/IProviderService.cs @@ -1,6 +1,9 @@ -using Bit.Core.AdminConsole.Entities.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -8,8 +11,7 @@ namespace Bit.Core.AdminConsole.Services; public interface IProviderService { - Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource = null); + Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress); Task UpdateAsync(Provider provider, bool updateBilling = false); Task> InviteUserAsync(ProviderUserInvite invite); diff --git a/src/Core/AdminConsole/Services/ISlackService.cs b/src/Core/AdminConsole/Services/ISlackService.cs index 6c6a846f0d..ff1e03f051 100644 --- a/src/Core/AdminConsole/Services/ISlackService.cs +++ b/src/Core/AdminConsole/Services/ISlackService.cs @@ -5,7 +5,7 @@ public interface ISlackService Task GetChannelIdAsync(string token, string channelName); Task> GetChannelIdsAsync(string token, List channelNames); Task GetDmChannelByEmailAsync(string token, string email); - string GetRedirectUrl(string redirectUrl); + string GetRedirectUrl(string callbackUrl, string state); Task ObtainTokenViaOAuth(string code, string redirectUrl); Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId); } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs index ffa148fc08..a589211687 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs @@ -1,28 +1,27 @@ -#nullable enable - -using System.Text; +using System.Text; using Azure.Messaging.ServiceBus; -using Bit.Core.Settings; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Extensions.Logging; namespace Bit.Core.Services; -public class AzureServiceBusEventListenerService : EventLoggingListenerService +public class AzureServiceBusEventListenerService : EventLoggingListenerService + where TConfiguration : IEventListenerConfiguration { private readonly ServiceBusProcessor _processor; public AzureServiceBusEventListenerService( + TConfiguration configuration, IEventMessageHandler handler, IAzureServiceBusService serviceBusService, - string subscriptionName, - GlobalSettings globalSettings, - ILogger logger) : base(handler, logger) + ServiceBusProcessorOptions serviceBusOptions, + ILoggerFactory loggerFactory) + : base(handler, CreateLogger(loggerFactory, configuration)) { _processor = serviceBusService.CreateProcessor( - globalSettings.EventLogging.AzureServiceBus.EventTopicName, - subscriptionName, - new ServiceBusProcessorOptions()); - _logger = logger; + topicName: configuration.EventTopicName, + subscriptionName: configuration.EventSubscriptionName, + options: serviceBusOptions); } protected override async Task ExecuteAsync(CancellationToken cancellationToken) @@ -40,6 +39,12 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService await base.StopAsync(cancellationToken); } + private static ILogger CreateLogger(ILoggerFactory loggerFactory, TConfiguration configuration) + { + return loggerFactory.CreateLogger( + categoryName: $"Bit.Core.Services.AzureServiceBusEventListenerService.{configuration.EventSubscriptionName}"); + } + internal Task ProcessErrorAsync(ProcessErrorEventArgs args) { _logger.LogError( diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs index 55a39ec774..633a53296b 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs @@ -1,32 +1,36 @@ -#nullable enable - -using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Bit.Core.Services; -public class AzureServiceBusIntegrationListenerService : BackgroundService +public class AzureServiceBusIntegrationListenerService : BackgroundService + where TConfiguration : IIntegrationListenerConfiguration { private readonly int _maxRetries; private readonly IAzureServiceBusService _serviceBusService; private readonly IIntegrationHandler _handler; private readonly ServiceBusProcessor _processor; - private readonly ILogger _logger; + private readonly ILogger _logger; - public AzureServiceBusIntegrationListenerService(IIntegrationHandler handler, - string topicName, - string subscriptionName, - int maxRetries, + public AzureServiceBusIntegrationListenerService( + TConfiguration configuration, + IIntegrationHandler handler, IAzureServiceBusService serviceBusService, - ILogger logger) + ServiceBusProcessorOptions serviceBusOptions, + ILoggerFactory loggerFactory) { _handler = handler; - _logger = logger; - _maxRetries = maxRetries; + _logger = loggerFactory.CreateLogger( + categoryName: $"Bit.Core.Services.AzureServiceBusIntegrationListenerService.{configuration.IntegrationSubscriptionName}"); + _maxRetries = configuration.MaxRetries; _serviceBusService = serviceBusService; - _processor = _serviceBusService.CreateProcessor(topicName, subscriptionName, new ServiceBusProcessorOptions()); + _processor = _serviceBusService.CreateProcessor( + topicName: configuration.IntegrationTopicName, + subscriptionName: configuration.IntegrationSubscriptionName, + options: serviceBusOptions); } protected override async Task ExecuteAsync(CancellationToken cancellationToken) diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs new file mode 100644 index 0000000000..45bb5b6d7d --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs @@ -0,0 +1,25 @@ +using System.Text; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +namespace Bit.Core.Services; + +public class DatadogIntegrationHandler( + IHttpClientFactory httpClientFactory, + TimeProvider timeProvider) + : IntegrationHandlerBase +{ + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + + public const string HttpClientName = "DatadogIntegrationHandlerHttpClient"; + + public override async Task HandleAsync(IntegrationMessage message) + { + var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Uri); + request.Content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json"); + request.Headers.Add("DD-API-KEY", message.Configuration.ApiKey); + + var response = await _httpClient.SendAsync(request); + + return ResultFromHttpResponse(response, message, timeProvider); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs index 519f8aeb32..309b4a8409 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.Models.Data; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs index 3ffd08edd2..0a8ab67554 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.AdminConsole.Utilities; using Bit.Core.Enums; @@ -14,7 +12,7 @@ public class EventIntegrationHandler( IntegrationType integrationType, IEventIntegrationPublisher eventIntegrationPublisher, IIntegrationFilterService integrationFilterService, - IOrganizationIntegrationConfigurationRepository configurationRepository, + IIntegrationConfigurationDetailsCache configurationCache, IUserRepository userRepository, IOrganizationRepository organizationRepository, ILogger> logger) @@ -27,7 +25,7 @@ public class EventIntegrationHandler( return; } - var configurations = await configurationRepository.GetConfigurationDetailsAsync( + var configurations = configurationCache.GetConfigurationDetails( organizationId, integrationType, eventMessage.Type); diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs index 0fab787589..ee3a2d5db2 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.Models.Data; +using Bit.Core.Models.Data; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs index df0819b409..a542e75a7b 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.Models.Data; +using Bit.Core.Models.Data; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs new file mode 100644 index 0000000000..a63efac62f --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs @@ -0,0 +1,83 @@ +using System.Diagnostics; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class IntegrationConfigurationDetailsCacheService : BackgroundService, IIntegrationConfigurationDetailsCache +{ + private readonly record struct IntegrationCacheKey(Guid OrganizationId, IntegrationType IntegrationType, EventType? EventType); + private readonly IOrganizationIntegrationConfigurationRepository _repository; + private readonly ILogger _logger; + private readonly TimeSpan _refreshInterval; + private Dictionary> _cache = new(); + + public IntegrationConfigurationDetailsCacheService( + IOrganizationIntegrationConfigurationRepository repository, + GlobalSettings globalSettings, + ILogger logger) + { + _repository = repository; + _logger = logger; + _refreshInterval = TimeSpan.FromMinutes(globalSettings.EventLogging.IntegrationCacheRefreshIntervalMinutes); + } + + public List GetConfigurationDetails( + Guid organizationId, + IntegrationType integrationType, + EventType eventType) + { + var specificKey = new IntegrationCacheKey(organizationId, integrationType, eventType); + var allEventsKey = new IntegrationCacheKey(organizationId, integrationType, null); + + var results = new List(); + + if (_cache.TryGetValue(specificKey, out var specificConfigs)) + { + results.AddRange(specificConfigs); + } + if (_cache.TryGetValue(allEventsKey, out var fallbackConfigs)) + { + results.AddRange(fallbackConfigs); + } + + return results; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await RefreshAsync(); + + var timer = new PeriodicTimer(_refreshInterval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await RefreshAsync(); + } + } + + internal async Task RefreshAsync() + { + var stopwatch = Stopwatch.StartNew(); + try + { + var newCache = (await _repository.GetAllConfigurationDetailsAsync()) + .GroupBy(x => new IntegrationCacheKey(x.OrganizationId, x.IntegrationType, x.EventType)) + .ToDictionary(g => g.Key, g => g.ToList()); + _cache = newCache; + + stopwatch.Stop(); + _logger.LogInformation( + "[IntegrationConfigurationDetailsCacheService] Refreshed successfully: {Count} entries in {Duration}ms", + newCache.Count, + stopwatch.Elapsed.TotalMilliseconds); + } + catch (Exception ex) + { + _logger.LogError("[IntegrationConfigurationDetailsCacheService] Refresh failed: {ex}", ex); + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs index b90ea8d16e..d28ac910b7 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Linq.Expressions; +using System.Linq.Expressions; using Bit.Core.Models.Data; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs index 88877c329a..1c8fae4000 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Models.Data; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md index 7320f8bab7..de7ce3f7fd 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -197,22 +197,37 @@ interface and therefore can also handle directly all the message publishing func Organizations can configure integration configurations to send events to different endpoints -- each handler maps to a specific integration and checks for the configuration when it receives an event. -Currently, there are integrations / handlers for Slack and webhooks (as mentioned above). +Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event Collector (HEC). ### `OrganizationIntegration` - The top-level object that enables a specific integration for the organization. - Includes any properties that apply to the entire integration across all events. - - For Slack, it consists of the token: `{ "token": "xoxb-token-from-slack" }` - - For webhooks, it is `null`. However, even though there is no configuration, an organization must - have a webhook `OrganizationIntegration` to enable configuration via `OrganizationIntegrationConfiguration`. + - For Slack, it consists of the token: `{ "Token": "xoxb-token-from-slack" }`. + - For webhooks, it is optional. Webhooks can either be configured at this level or the configuration level, + but the configuration level takes precedence. However, even though it is optional, an organization must + have a webhook `OrganizationIntegration` (even will a `null` `Configuration`) to enable configuration + via `OrganizationIntegrationConfiguration`. + - For HEC, it consists of the scheme, token, and URI: + +```json + { + "Scheme": "Bearer", + "Token": "Auth-token-from-HEC-service", + "Uri": "https://example.com/api" + } +``` ### `OrganizationIntegrationConfiguration` - This contains the configurations specific to each `EventType` for the integration. - `Configuration` contains the event-specific configuration. - For Slack, this would contain what channel to send the message to: `{ "channelId": "C123456" }` - - For Webhook, this is the URL the request should be sent to: `{ "url": "https://api.example.com" }` + - For webhooks, this is the URL the request should be sent to: `{ "url": "https://api.example.com" }` + - Optionally this also can include a `Scheme` and `Token` if this webhook needs Authentication. + - As stated above, all of this information can be specified here or at the `OrganizationIntegration` + level, but any properties declared here will take precedence over the ones above. + - For HEC, this must be null. HEC is configured only at the `OrganizationIntegration` level. - `Template` contains a template string that is expected to be filled in with the contents of the actual event. - The tokens in the string are wrapped in `#` characters. For instance, the UserId would be `#UserId#`. - The `IntegrationTemplateProcessor` does the actual work of replacing these tokens with introspected values from @@ -225,6 +240,8 @@ Currently, there are integrations / handlers for Slack and webhooks (as mentione - This is the combination of both the `OrganizationIntegration` and `OrganizationIntegrationConfiguration` into a single object. The combined contents tell the integration's handler all the details needed to send to an external service. +- `OrganizationIntegrationConfiguration` takes precedence over `OrganizationIntegration` - any keys present in + both will receive the value declared in `OrganizationIntegrationConfiguration`. - An array of `OrganizationIntegrationConfigurationDetails` is what the `EventIntegrationHandler` fetches from the database to determine what to publish at the integration level. @@ -273,11 +290,41 @@ graph TD C1 -->|Has many| B1_2[IntegrationFilterRule] C1 -->|Can contain| C2[IntegrationFilterGroup...] ``` +## Caching + +To reduce database load and improve performance, integration configurations are cached in-memory as a Dictionary +with a periodic load of all configurations. Without caching, each incoming `EventMessage` would trigger a database +query to retrieve the relevant `OrganizationIntegrationConfigurationDetails`. + +By loading all configurations into memory on a fixed interval, we ensure: + +- Consistent performance for reads. +- Reduced database pressure. +- Predictable refresh timing, independent of event activity. + +### Architecture / Design + +- The cache is read-only for consumers. It is only updated in bulk by a background refresh process. +- The cache is fully replaced on each refresh to avoid locking or partial state. +- Reads return a `List` for a given key or an empty list if no + match exists. +- Failures or delays in the loading process do not affect the existing cache state. The cache will continue serving + the last known good state until the update replaces the whole cache. + +### Background Refresh + +A hosted service (`IntegrationConfigurationDetailsCacheService`) runs in the background and: + +- Loads all configuration records at application startup. +- Refreshes the cache on a configurable interval. +- Logs timing and entry count on success. +- Logs exceptions on failure without disrupting application flow. # Building a new integration These are all the pieces required in the process of building out a new integration. For -clarity in naming, these assume a new integration called "Example". +clarity in naming, these assume a new integration called "Example". To see a complete example +in context, view [the PR for adding the Datadog integration](https://github.com/bitwarden/server/pull/6289). ## IntegrationType @@ -353,35 +400,52 @@ These names added here are what must match the values provided in the secrets or in Global Settings. This must be in place (and the local ASB emulator restarted) before you can use any code locally that accesses ASB resources. +## ListenerConfiguration + +New integrations will need their own subclass of `ListenerConfiguration` which also conforms to +`IIntegrationListenerConfiguration`. This class provides a way of accessing the previously configured +RabbitMQ queues and ASB subscriptions by referring to the values created in `GlobalSettings`. This new +listener configuration will be used to type the listener and provide the means to access the necessary +configurations for the integration. + ## ServiceCollectionExtensions + In our `ServiceCollectionExtensions`, we pull all the above pieces together to start listeners on each message -tier with handlers to process the integration. There are a number of helper methods in here to make this simple -to add a new integration - one call per platform. +tier with handlers to process the integration. -Also note that if an integration needs a custom singleton / service defined, the add listeners method is a -good place to set that up. For instance, `SlackIntegrationHandler` needs a `SlackService`, so the singleton -declaration is right above the add integration method for slack. Same thing for webhooks when it comes to -defining a custom HttpClient by name. +The core method for all event integration setup is `AddEventIntegrationServices`. This method is called by +both of the add listeners methods, which ensures that we have one common place to set up cross-messaging-platform +dependencies and integrations. For instance, `SlackIntegrationHandler` needs a `SlackService`, so +`AddEventIntegrationServices` has a call to `AddSlackService`. Same thing for webhooks when it +comes to defining a custom HttpClient by name. + +In `AddEventIntegrationServices`: + +1. Create the singleton for the handler: -1. In `AddRabbitMqListeners` add the integration: ``` csharp - services.AddRabbitMqIntegration( - globalSettings.EventLogging.RabbitMq.ExampleEventsQueueName, - globalSettings.EventLogging.RabbitMq.ExampleIntegrationQueueName, - globalSettings.EventLogging.RabbitMq.ExampleIntegrationRetryQueueName, - globalSettings.EventLogging.RabbitMq.MaxRetries, - IntegrationType.Example); + services.TryAddSingleton, ExampleIntegrationHandler>(); ``` -2. In `AddAzureServiceBusListeners` add the integration: +2. Create the listener configuration: + ``` csharp -services.AddAzureServiceBusIntegration( - eventSubscriptionName: globalSettings.EventLogging.AzureServiceBus.ExampleEventSubscriptionName, - integrationSubscriptionName: globalSettings.EventLogging.AzureServiceBus.ExampleIntegrationSubscriptionName, - integrationType: IntegrationType.Example, - globalSettings: globalSettings); + var exampleConfiguration = new ExampleListenerConfiguration(globalSettings); ``` +3. Add the integration to both the RabbitMQ and ASB specific declarations: + +``` csharp + services.AddRabbitMqIntegration(exampleConfiguration); +``` + +and + +``` csharp + services.AddAzureServiceBusIntegration(exampleConfiguration); +``` + + # Deploying a new integration ## RabbitMQ diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs index bc2329930d..430540a2f7 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs @@ -1,13 +1,13 @@ -#nullable enable - -using System.Text; +using System.Text; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Extensions.Logging; using RabbitMQ.Client; using RabbitMQ.Client.Events; namespace Bit.Core.Services; -public class RabbitMqEventListenerService : EventLoggingListenerService +public class RabbitMqEventListenerService : EventLoggingListenerService + where TConfiguration : IEventListenerConfiguration { private readonly Lazy> _lazyChannel; private readonly string _queueName; @@ -15,12 +15,12 @@ public class RabbitMqEventListenerService : EventLoggingListenerService public RabbitMqEventListenerService( IEventMessageHandler handler, - string queueName, + TConfiguration configuration, IRabbitMqService rabbitMqService, - ILogger logger) : base(handler, logger) + ILoggerFactory loggerFactory) + : base(handler, CreateLogger(loggerFactory, configuration)) { - _logger = logger; - _queueName = queueName; + _queueName = configuration.EventQueueName; _rabbitMqService = rabbitMqService; _lazyChannel = new Lazy>(() => _rabbitMqService.CreateChannelAsync()); } @@ -65,4 +65,10 @@ public class RabbitMqEventListenerService : EventLoggingListenerService } base.Dispose(); } + + private static ILogger CreateLogger(ILoggerFactory loggerFactory, TConfiguration configuration) + { + return loggerFactory.CreateLogger( + categoryName: $"Bit.Core.Services.RabbitMqEventListenerService.{configuration.EventQueueName}"); + } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs index db6a7f9510..b426032c92 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text; +using System.Text; using System.Text.Json; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Extensions.Hosting; @@ -10,7 +8,8 @@ using RabbitMQ.Client.Events; namespace Bit.Core.Services; -public class RabbitMqIntegrationListenerService : BackgroundService +public class RabbitMqIntegrationListenerService : BackgroundService + where TConfiguration : IIntegrationListenerConfiguration { private readonly int _maxRetries; private readonly string _queueName; @@ -19,27 +18,26 @@ public class RabbitMqIntegrationListenerService : BackgroundService private readonly IIntegrationHandler _handler; private readonly Lazy> _lazyChannel; private readonly IRabbitMqService _rabbitMqService; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly TimeProvider _timeProvider; - public RabbitMqIntegrationListenerService(IIntegrationHandler handler, - string routingKey, - string queueName, - string retryQueueName, - int maxRetries, + public RabbitMqIntegrationListenerService( + IIntegrationHandler handler, + TConfiguration configuration, IRabbitMqService rabbitMqService, - ILogger logger, + ILoggerFactory loggerFactory, TimeProvider timeProvider) { _handler = handler; - _routingKey = routingKey; - _retryQueueName = retryQueueName; - _queueName = queueName; + _maxRetries = configuration.MaxRetries; + _routingKey = configuration.RoutingKey; + _retryQueueName = configuration.IntegrationRetryQueueName; + _queueName = configuration.IntegrationQueueName; _rabbitMqService = rabbitMqService; - _logger = logger; _timeProvider = timeProvider; - _maxRetries = maxRetries; _lazyChannel = new Lazy>(() => _rabbitMqService.CreateChannelAsync()); + _logger = loggerFactory.CreateLogger( + categoryName: $"Bit.Core.Services.RabbitMqIntegrationListenerService.{configuration.IntegrationQueueName}"); ; } public override async Task StartAsync(CancellationToken cancellationToken) diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs index 20ae31a113..3e20e34200 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text; +using System.Text; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; using Bit.Core.Settings; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs index 6f55c0cf9c..2d29494afc 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs index 3f82217830..4fb74f1f44 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Web; using Bit.Core.Models.Slack; @@ -21,6 +19,7 @@ public class SlackService( private readonly string _slackApiBaseUrl = globalSettings.Slack.ApiBaseUrl; public const string HttpClientName = "SlackServiceHttpClient"; + private const string _slackOAuthBaseUri = "https://slack.com/oauth/v2/authorize"; public async Task GetChannelIdAsync(string token, string channelName) { @@ -75,9 +74,18 @@ public class SlackService( return await OpenDmChannel(token, userId); } - public string GetRedirectUrl(string redirectUrl) + public string GetRedirectUrl(string callbackUrl, string state) { - return $"https://slack.com/oauth/v2/authorize?client_id={_clientId}&scope={_scopes}&redirect_uri={redirectUrl}"; + var builder = new UriBuilder(_slackOAuthBaseUri); + var query = HttpUtility.ParseQueryString(builder.Query); + + query["client_id"] = _clientId; + query["scope"] = _scopes; + query["redirect_uri"] = callbackUrl; + query["state"] = state; + + builder.Query = query.ToString(); + return builder.ToString(); } public async Task ObtainTokenViaOAuth(string code, string redirectUrl) diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs index b66df59a69..0599f6e9d4 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs @@ -1,13 +1,7 @@ -#nullable enable - -using System.Globalization; -using System.Net; -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Text; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; -#nullable enable - namespace Bit.Core.Services; public class WebhookIntegrationHandler( @@ -19,9 +13,10 @@ public class WebhookIntegrationHandler( public const string HttpClientName = "WebhookIntegrationHandlerHttpClient"; - public override async Task HandleAsync(IntegrationMessage message) + public override async Task HandleAsync( + IntegrationMessage message) { - var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Url); + var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Uri); request.Content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json"); if (!string.IsNullOrEmpty(message.Configuration.Scheme)) { @@ -30,45 +25,8 @@ public class WebhookIntegrationHandler( parameter: message.Configuration.Token ); } + var response = await _httpClient.SendAsync(request); - var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message); - - switch (response.StatusCode) - { - case HttpStatusCode.TooManyRequests: - case HttpStatusCode.RequestTimeout: - case HttpStatusCode.InternalServerError: - case HttpStatusCode.BadGateway: - case HttpStatusCode.ServiceUnavailable: - case HttpStatusCode.GatewayTimeout: - result.Retryable = true; - result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}"; - - if (response.Headers.TryGetValues("Retry-After", out var values)) - { - var value = values.FirstOrDefault(); - if (int.TryParse(value, out var seconds)) - { - // Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds. - result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; - } - else if (DateTimeOffset.TryParseExact(value, - "r", // "r" is the round-trip format: RFC1123 - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, - out var retryDate)) - { - // Retry-after was specified as a date. Adjust DelayUntilDate to the specified date. - result.DelayUntilDate = retryDate.UtcDateTime; - } - } - break; - default: - result.Retryable = false; - result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; - break; - } - - return result; + return ResultFromHttpResponse(response, message, timeProvider); } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventService.cs b/src/Core/AdminConsole/Services/Implementations/EventService.cs index 88d9595b4a..77d481890e 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventService.cs @@ -1,8 +1,12 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Interfaces; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -409,9 +413,30 @@ public class EventService : IEventService await _eventWriteService.CreateAsync(e); } - public async Task LogServiceAccountSecretEventAsync(Guid serviceAccountId, Secret secret, EventType type, DateTime? date = null) + public async Task LogUserSecretsEventAsync(Guid userId, IEnumerable secrets, EventType type, DateTime? date = null) { - await LogServiceAccountSecretsEventAsync(serviceAccountId, new[] { secret }, type, date); + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + + foreach (var secret in secrets) + { + if (!CanUseEvents(orgAbilities, secret.OrganizationId)) + { + continue; + } + + var e = new EventMessage(_currentContext) + { + OrganizationId = secret.OrganizationId, + Type = type, + SecretId = secret.Id, + UserId = userId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + } + + await _eventWriteService.CreateManyAsync(eventMessages); } public async Task LogServiceAccountSecretsEventAsync(Guid serviceAccountId, IEnumerable secrets, EventType type, DateTime? date = null) @@ -440,6 +465,187 @@ public class EventService : IEventService await _eventWriteService.CreateManyAsync(eventMessages); } + public async Task LogUserProjectsEventAsync(Guid userId, IEnumerable projects, EventType type, DateTime? date = null) + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + + foreach (var project in projects) + { + if (!CanUseEvents(orgAbilities, project.OrganizationId)) + { + continue; + } + + var e = new EventMessage(_currentContext) + { + OrganizationId = project.OrganizationId, + Type = type, + ProjectId = project.Id, + UserId = userId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + } + + await _eventWriteService.CreateManyAsync(eventMessages); + } + + public async Task LogServiceAccountProjectsEventAsync(Guid serviceAccountId, IEnumerable projects, EventType type, DateTime? date = null) + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + + foreach (var project in projects) + { + if (!CanUseEvents(orgAbilities, project.OrganizationId)) + { + continue; + } + + var e = new EventMessage(_currentContext) + { + OrganizationId = project.OrganizationId, + Type = type, + ProjectId = project.Id, + ServiceAccountId = serviceAccountId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + } + + await _eventWriteService.CreateManyAsync(eventMessages); + } + + + public async Task LogServiceAccountPeopleEventAsync(Guid userId, UserServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + var orgUser = await _organizationUserRepository.GetByIdAsync((Guid)policy.OrganizationUserId); + + if (!CanUseEvents(orgAbilities, orgUser.OrganizationId)) + { + return; + } + + var (actingUserId, serviceAccountId) = MapIdentityClientType(userId, identityClientType); + + if (actingUserId is null && serviceAccountId is null) + { + return; + } + + if (policy.OrganizationUserId != null) + { + var e = new EventMessage(_currentContext) + { + OrganizationId = orgUser.OrganizationId, + Type = type, + GrantedServiceAccountId = policy.GrantedServiceAccountId, + ServiceAccountId = serviceAccountId, + UserId = policy.OrganizationUserId, + ActingUserId = actingUserId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + + await _eventWriteService.CreateManyAsync(eventMessages); + } + } + + public async Task LogServiceAccountGroupEventAsync(Guid userId, GroupServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + + if (!CanUseEvents(orgAbilities, policy.Group.OrganizationId)) + { + return; + } + + var (actingUserId, serviceAccountId) = MapIdentityClientType(userId, identityClientType); + + if (actingUserId is null && serviceAccountId is null) + { + return; + } + + if (policy.GroupId != null) + { + var e = new EventMessage(_currentContext) + { + OrganizationId = policy.Group.OrganizationId, + Type = type, + GrantedServiceAccountId = policy.GrantedServiceAccountId, + ServiceAccountId = serviceAccountId, + GroupId = policy.GroupId, + ActingUserId = actingUserId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + + await _eventWriteService.CreateManyAsync(eventMessages); + } + } + + public async Task LogServiceAccountEventAsync(Guid userId, List serviceAccounts, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + + foreach (var serviceAccount in serviceAccounts) + { + if (!CanUseEvents(orgAbilities, serviceAccount.OrganizationId)) + { + continue; + } + + var (actingUserId, serviceAccountId) = MapIdentityClientType(userId, identityClientType); + + if (actingUserId is null && serviceAccountId is null) + { + continue; + } + + if (serviceAccount != null) + { + var e = new EventMessage(_currentContext) + { + OrganizationId = serviceAccount.OrganizationId, + Type = type, + GrantedServiceAccountId = serviceAccount.Id, + ServiceAccountId = serviceAccountId, + ActingUserId = actingUserId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + } + } + + if (eventMessages.Any()) + { + await _eventWriteService.CreateManyAsync(eventMessages); + } + } + + private (Guid? actingUserId, Guid? serviceAccountId) MapIdentityClientType( + Guid userId, IdentityClientType identityClientType) + { + if (identityClientType == IdentityClientType.Organization) + { + return (null, null); + } + + return identityClientType switch + { + IdentityClientType.User => (userId, null), + IdentityClientType.ServiceAccount => (null, userId), + _ => throw new InvalidOperationException("Unknown identity client type.") + }; + } + + private async Task GetProviderIdAsync(Guid? orgId) { if (_currentContext == null || !orgId.HasValue) diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 7947cbefff..1b52ad8cff 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; @@ -11,6 +14,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.AdminConsole.Utilities.DebuggingInstruments; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; using Bit.Core.Billing.Constants; @@ -23,7 +27,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.Data; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Platform.Push; using Bit.Core.Repositories; @@ -39,13 +42,9 @@ public class OrganizationService : IOrganizationService { private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly ICollectionRepository _collectionRepository; private readonly IGroupRepository _groupRepository; private readonly IMailService _mailService; private readonly IPushNotificationService _pushNotificationService; - private readonly IPushRegistrationService _pushRegistrationService; - private readonly IDeviceRepository _deviceRepository; - private readonly ILicensingService _licensingService; private readonly IEventService _eventService; private readonly IApplicationCacheService _applicationCacheService; private readonly IPaymentService _paymentService; @@ -53,7 +52,6 @@ public class OrganizationService : IOrganizationService private readonly IPolicyService _policyService; private readonly ISsoUserRepository _ssoUserRepository; private readonly IGlobalSettings _globalSettings; - private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly IProviderOrganizationRepository _providerOrganizationRepository; @@ -66,17 +64,14 @@ public class OrganizationService : IOrganizationService private readonly IPricingClient _pricingClient; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly IStripeAdapter _stripeAdapter; public OrganizationService( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, - ICollectionRepository collectionRepository, IGroupRepository groupRepository, IMailService mailService, IPushNotificationService pushNotificationService, - IPushRegistrationService pushRegistrationService, - IDeviceRepository deviceRepository, - ILicensingService licensingService, IEventService eventService, IApplicationCacheService applicationCacheService, IPaymentService paymentService, @@ -84,7 +79,6 @@ public class OrganizationService : IOrganizationService IPolicyService policyService, ISsoUserRepository ssoUserRepository, IGlobalSettings globalSettings, - IOrganizationApiKeyRepository organizationApiKeyRepository, ICurrentContext currentContext, ILogger logger, IProviderOrganizationRepository providerOrganizationRepository, @@ -96,18 +90,15 @@ public class OrganizationService : IOrganizationService IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IPricingClient pricingClient, IPolicyRequirementQuery policyRequirementQuery, - ISendOrganizationInvitesCommand sendOrganizationInvitesCommand + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + IStripeAdapter stripeAdapter ) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; - _collectionRepository = collectionRepository; _groupRepository = groupRepository; _mailService = mailService; _pushNotificationService = pushNotificationService; - _pushRegistrationService = pushRegistrationService; - _deviceRepository = deviceRepository; - _licensingService = licensingService; _eventService = eventService; _applicationCacheService = applicationCacheService; _paymentService = paymentService; @@ -115,7 +106,6 @@ public class OrganizationService : IOrganizationService _policyService = policyService; _ssoUserRepository = ssoUserRepository; _globalSettings = globalSettings; - _organizationApiKeyRepository = organizationApiKeyRepository; _currentContext = currentContext; _logger = logger; _providerOrganizationRepository = providerOrganizationRepository; @@ -128,24 +118,7 @@ public class OrganizationService : IOrganizationService _pricingClient = pricingClient; _policyRequirementQuery = policyRequirementQuery; _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; - } - - public async Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null) - { - var organization = await GetOrgById(organizationId); - if (organization == null) - { - throw new NotFoundException(); - } - - var eop = endOfPeriod.GetValueOrDefault(true); - if (!endOfPeriod.HasValue && organization.ExpirationDate.HasValue && - organization.ExpirationDate.Value < DateTime.UtcNow) - { - eop = false; - } - - await _paymentService.CancelSubscriptionAsync(organization, eop); + _stripeAdapter = stripeAdapter; } public async Task ReinstateSubscriptionAsync(Guid organizationId) @@ -319,8 +292,14 @@ public class OrganizationService : IOrganizationService throw new BadRequestException("You cannot have more Secrets Manager seats than Password Manager seats."); } + _logger.LogInformation("{Method}: Invoking _paymentService.AdjustSeatsAsync with {AdditionalSeats} additional seats for Organization ({OrganizationID})", + nameof(AdjustSeatsAsync), additionalSeats, organization.Id); + var paymentIntentClientSecret = await _paymentService.AdjustSeatsAsync(organization, plan, additionalSeats); organization.Seats = (short?)newSeatTotal; + + _logger.LogInformation("{Method}: Invoking _organizationRepository.ReplaceAsync with {Seats} seats for Organization ({OrganizationID})", nameof(AdjustSeatsAsync), organization.Seats, organization.Id); ; + await ReplaceAndUpdateCacheAsync(organization); if (organization.Seats.HasValue && organization.MaxAutoscaleSeats.HasValue && @@ -346,199 +325,6 @@ public class OrganizationService : IOrganizationService return paymentIntentClientSecret; } - public async Task VerifyBankAsync(Guid organizationId, int amount1, int amount2) - { - var organization = await GetOrgById(organizationId); - if (organization == null) - { - throw new NotFoundException(); - } - - if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) - { - throw new GatewayException("Not a gateway customer."); - } - - var bankService = new BankAccountService(); - var customerService = new CustomerService(); - var customer = await customerService.GetAsync(organization.GatewayCustomerId, - new CustomerGetOptions { Expand = new List { "sources" } }); - if (customer == null) - { - throw new GatewayException("Cannot find customer."); - } - - var bankAccount = customer.Sources - .FirstOrDefault(s => s is BankAccount && ((BankAccount)s).Status != "verified") as BankAccount; - if (bankAccount == null) - { - throw new GatewayException("Cannot find an unverified bank account."); - } - - try - { - var result = await bankService.VerifyAsync(organization.GatewayCustomerId, bankAccount.Id, - new BankAccountVerifyOptions { Amounts = new List { amount1, amount2 } }); - if (result.Status != "verified") - { - throw new GatewayException("Unable to verify account."); - } - } - catch (StripeException e) - { - throw new GatewayException(e.Message); - } - } - - private async Task ValidateSignUpPoliciesAsync(Guid ownerId) - { - var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); - if (anySingleOrgPolicies) - { - throw new BadRequestException("You may not create an organization. You belong to an organization " + - "which has a policy that prohibits you from being a member of any other organization."); - } - } - - /// - /// Create a new organization on a self-hosted instance - /// - public async Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync( - OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey, - string privateKey) - { - if (license.LicenseType != LicenseType.Organization) - { - throw new BadRequestException("Premium licenses cannot be applied to an organization. " + - "Upload this license from your personal account settings page."); - } - - var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); - var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception); - - if (!canUse) - { - throw new BadRequestException(exception); - } - - var enabledOrgs = await _organizationRepository.GetManyByEnabledAsync(); - if (enabledOrgs.Any(o => string.Equals(o.LicenseKey, license.LicenseKey))) - { - throw new BadRequestException("License is already in use by another organization."); - } - - await ValidateSignUpPoliciesAsync(owner.Id); - - var organization = claimsPrincipal != null - // If the ClaimsPrincipal exists (there's a token on the license), use it to build the organization. - ? OrganizationFactory.Create(owner, claimsPrincipal, publicKey, privateKey) - // If there's no ClaimsPrincipal (there's no token on the license), use the license to build the organization. - : OrganizationFactory.Create(owner, license, publicKey, privateKey); - - var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); - - var dir = $"{_globalSettings.LicenseDirectory}/organization"; - Directory.CreateDirectory(dir); - await using var fs = new FileStream(Path.Combine(dir, $"{organization.Id}.json"), FileMode.Create); - await JsonSerializer.SerializeAsync(fs, license, JsonHelpers.Indented); - return (result.organization, result.organizationUser); - } - - /// - /// Private helper method to create a new organization. - /// This is common code used by both the cloud and self-hosted methods. - /// - private async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> - SignUpAsync(Organization organization, - Guid ownerId, string ownerKey, string collectionName, bool withPayment) - { - try - { - await _organizationRepository.CreateAsync(organization); - await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey - { - OrganizationId = organization.Id, - ApiKey = CoreHelpers.SecureRandomString(30), - Type = OrganizationApiKeyType.Default, - RevisionDate = DateTime.UtcNow, - }); - await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); - - // ownerId == default if the org is created by a provider - in this case it's created without an - // owner and the first owner is immediately invited afterwards - OrganizationUser orgUser = null; - if (ownerId != default) - { - orgUser = new OrganizationUser - { - OrganizationId = organization.Id, - UserId = ownerId, - Key = ownerKey, - AccessSecretsManager = organization.UseSecretsManager, - Type = OrganizationUserType.Owner, - Status = OrganizationUserStatusType.Confirmed, - CreationDate = organization.CreationDate, - RevisionDate = organization.CreationDate - }; - orgUser.SetNewId(); - - await _organizationUserRepository.CreateAsync(orgUser); - - var devices = await GetUserDeviceIdsAsync(orgUser.UserId.Value); - await _pushRegistrationService.AddUserRegistrationOrganizationAsync(devices, - organization.Id.ToString()); - await _pushNotificationService.PushSyncOrgKeysAsync(ownerId); - } - - Collection defaultCollection = null; - if (!string.IsNullOrWhiteSpace(collectionName)) - { - defaultCollection = new Collection - { - Name = collectionName, - OrganizationId = organization.Id, - CreationDate = organization.CreationDate, - RevisionDate = organization.CreationDate - }; - - // Give the owner Can Manage access over the default collection - List defaultOwnerAccess = null; - if (orgUser != null) - { - defaultOwnerAccess = - [ - new CollectionAccessSelection - { - Id = orgUser.Id, - HidePasswords = false, - ReadOnly = false, - Manage = true - } - ]; - } - - await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); - } - - return (organization, orgUser, defaultCollection); - } - catch - { - if (withPayment) - { - await _paymentService.CancelAndRecoverChargesAsync(organization); - } - - if (organization.Id != default(Guid)) - { - await _organizationRepository.DeleteAsync(organization); - await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); - } - - throw; - } - } - public async Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate) { var org = await GetOrgById(organizationId); @@ -550,8 +336,7 @@ public class OrganizationService : IOrganizationService } } - public async Task UpdateAsync(Organization organization, bool updateBilling = false, - EventType eventType = EventType.Organization_Updated) + public async Task UpdateAsync(Organization organization, bool updateBilling = false) { if (organization.Id == default(Guid)) { @@ -567,23 +352,60 @@ public class OrganizationService : IOrganizationService } } - await ReplaceAndUpdateCacheAsync(organization, eventType); + await ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); if (updateBilling && !string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) { - var customerService = new CustomerService(); - await customerService.UpdateAsync(organization.GatewayCustomerId, + var newDisplayName = organization.DisplayName(); + + await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions { Email = organization.BillingEmail, - Description = organization.DisplayBusinessName() + Description = organization.DisplayBusinessName(), + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + // This overwrites the existing custom fields for this organization + CustomFields = [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = organization.SubscriberType(), + Value = newDisplayName.Length <= 30 + ? newDisplayName + : newDisplayName[..30] + }] + }, }); } + } - if (eventType == EventType.Organization_CollectionManagement_Updated) + public async Task UpdateCollectionManagementSettingsAsync(Guid organizationId, OrganizationCollectionManagementSettings settings) + { + var existingOrganization = await _organizationRepository.GetByIdAsync(organizationId); + if (existingOrganization == null) { - await _pushNotificationService.PushSyncOrganizationCollectionManagementSettingsAsync(organization); + throw new NotFoundException(); } + + // Create logging actions based on what will change + var loggingActions = CreateCollectionManagementLoggingActions(existingOrganization, settings); + + existingOrganization.LimitCollectionCreation = settings.LimitCollectionCreation; + existingOrganization.LimitCollectionDeletion = settings.LimitCollectionDeletion; + existingOrganization.LimitItemDeletion = settings.LimitItemDeletion; + existingOrganization.AllowAdminAccessToAllCollectionItems = settings.AllowAdminAccessToAllCollectionItems; + existingOrganization.RevisionDate = DateTime.UtcNow; + + await ReplaceAndUpdateCacheAsync(existingOrganization); + + if (loggingActions.Any()) + { + await Task.WhenAll(loggingActions.Select(action => action())); + } + + await _pushNotificationService.PushSyncOrganizationCollectionManagementSettingsAsync(existingOrganization); + + return existingOrganization; } public async Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type) @@ -900,6 +722,8 @@ public class OrganizationService : IOrganizationService IEnumerable organizationUsersId) { var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId); + _logger.LogUserInviteStateDiagnostics(orgUsers); + var org = await GetOrgById(organizationId); var result = new List>(); @@ -918,19 +742,6 @@ public class OrganizationService : IOrganizationService return result; } - public async Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, - bool initOrganization = false) - { - var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - if (orgUser == null || orgUser.OrganizationId != organizationId || - orgUser.Status != OrganizationUserStatusType.Invited) - { - throw new BadRequestException("User invalid."); - } - - var org = await GetOrgById(orgUser.OrganizationId); - await SendInviteAsync(orgUser, org, initOrganization); - } private async Task SendInvitesAsync(IEnumerable orgUsers, Organization organization) => await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization)); @@ -1091,214 +902,6 @@ public class OrganizationService : IOrganizationService : EventType.OrganizationUser_ResetPassword_Withdraw); } - public async Task ImportAsync(Guid organizationId, - IEnumerable groups, - IEnumerable newUsers, - IEnumerable removeUserExternalIds, - bool overwriteExisting, - EventSystemUser eventSystemUser - ) - { - var organization = await GetOrgById(organizationId); - if (organization == null) - { - throw new NotFoundException(); - } - - if (!organization.UseDirectory) - { - throw new BadRequestException("Organization cannot use directory syncing."); - } - - var newUsersSet = new HashSet(newUsers?.Select(u => u.ExternalId) ?? new List()); - var existingUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); - var existingExternalUsers = existingUsers.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); - var existingExternalUsersIdDict = existingExternalUsers.ToDictionary(u => u.ExternalId, u => u.Id); - - // Users - - var events = new List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)>(); - - // Remove Users - if (removeUserExternalIds?.Any() ?? false) - { - var existingUsersDict = existingExternalUsers.ToDictionary(u => u.ExternalId); - var removeUsersSet = new HashSet(removeUserExternalIds) - .Except(newUsersSet) - .Where(u => existingUsersDict.TryGetValue(u, out var existingUser) && - existingUser.Type != OrganizationUserType.Owner) - .Select(u => existingUsersDict[u]); - - await _organizationUserRepository.DeleteManyAsync(removeUsersSet.Select(u => u.Id)); - events.AddRange(removeUsersSet.Select(u => ( - u, - EventType.OrganizationUser_Removed, - (DateTime?)DateTime.UtcNow - )) - ); - } - - if (overwriteExisting) - { - // Remove existing external users that are not in new user set - var usersToDelete = existingExternalUsers.Where(u => - u.Type != OrganizationUserType.Owner && - !newUsersSet.Contains(u.ExternalId) && - existingExternalUsersIdDict.ContainsKey(u.ExternalId)); - await _organizationUserRepository.DeleteManyAsync(usersToDelete.Select(u => u.Id)); - events.AddRange(usersToDelete.Select(u => ( - u, - EventType.OrganizationUser_Removed, - (DateTime?)DateTime.UtcNow - )) - ); - foreach (var deletedUser in usersToDelete) - { - existingExternalUsersIdDict.Remove(deletedUser.ExternalId); - } - } - - if (newUsers?.Any() ?? false) - { - // Marry existing users - var existingUsersEmailsDict = existingUsers - .Where(u => string.IsNullOrWhiteSpace(u.ExternalId)) - .ToDictionary(u => u.Email); - var newUsersEmailsDict = newUsers.ToDictionary(u => u.Email); - var usersToAttach = existingUsersEmailsDict.Keys.Intersect(newUsersEmailsDict.Keys).ToList(); - var usersToUpsert = new List(); - foreach (var user in usersToAttach) - { - var orgUserDetails = existingUsersEmailsDict[user]; - var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserDetails.Id); - if (orgUser != null) - { - orgUser.ExternalId = newUsersEmailsDict[user].ExternalId; - usersToUpsert.Add(orgUser); - existingExternalUsersIdDict.Add(orgUser.ExternalId, orgUser.Id); - } - } - - await _organizationUserRepository.UpsertManyAsync(usersToUpsert); - - // Add new users - var existingUsersSet = new HashSet(existingExternalUsersIdDict.Keys); - var usersToAdd = newUsersSet.Except(existingUsersSet).ToList(); - - var seatsAvailable = int.MaxValue; - var enoughSeatsAvailable = true; - if (organization.Seats.HasValue) - { - var seatCounts = - await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - seatsAvailable = organization.Seats.Value - seatCounts.Total; - enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; - } - - var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); - - var userInvites = new List<(OrganizationUserInvite, string)>(); - foreach (var user in newUsers) - { - if (!usersToAdd.Contains(user.ExternalId) || string.IsNullOrWhiteSpace(user.Email)) - { - continue; - } - - try - { - var invite = new OrganizationUserInvite - { - Emails = new List { user.Email }, - Type = OrganizationUserType.User, - Collections = new List(), - AccessSecretsManager = hasStandaloneSecretsManager - }; - userInvites.Add((invite, user.ExternalId)); - } - catch (BadRequestException) - { - // Thrown when the user is already invited to the organization - continue; - } - } - - var invitedUsers = await InviteUsersAsync(organizationId, invitingUserId: null, systemUser: eventSystemUser, - userInvites); - foreach (var invitedUser in invitedUsers) - { - existingExternalUsersIdDict.Add(invitedUser.ExternalId, invitedUser.Id); - } - } - - - // Groups - if (groups?.Any() ?? false) - { - if (!organization.UseGroups) - { - throw new BadRequestException("Organization cannot use groups."); - } - - var groupsDict = groups.ToDictionary(g => g.Group.ExternalId); - var existingGroups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId); - var existingExternalGroups = existingGroups - .Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); - var existingExternalGroupsDict = existingExternalGroups.ToDictionary(g => g.ExternalId); - - var newGroups = groups - .Where(g => !existingExternalGroupsDict.ContainsKey(g.Group.ExternalId)) - .Select(g => g.Group).ToList(); - - var savedGroups = new List(); - foreach (var group in newGroups) - { - group.CreationDate = group.RevisionDate = DateTime.UtcNow; - - savedGroups.Add(await _groupRepository.CreateAsync(group)); - await UpdateUsersAsync(group, groupsDict[group.ExternalId].ExternalUserIds, - existingExternalUsersIdDict); - } - - await _eventService.LogGroupEventsAsync( - savedGroups.Select(g => (g, EventType.Group_Created, (EventSystemUser?)eventSystemUser, - (DateTime?)DateTime.UtcNow))); - - var updateGroups = existingExternalGroups - .Where(g => groupsDict.ContainsKey(g.ExternalId)) - .ToList(); - - if (updateGroups.Any()) - { - var groupUsers = await _groupRepository.GetManyGroupUsersByOrganizationIdAsync(organizationId); - var existingGroupUsers = groupUsers - .GroupBy(gu => gu.GroupId) - .ToDictionary(g => g.Key, g => new HashSet(g.Select(gr => gr.OrganizationUserId))); - - foreach (var group in updateGroups) - { - var updatedGroup = groupsDict[group.ExternalId].Group; - if (group.Name != updatedGroup.Name) - { - group.RevisionDate = DateTime.UtcNow; - group.Name = updatedGroup.Name; - - await _groupRepository.ReplaceAsync(group); - } - - await UpdateUsersAsync(group, groupsDict[group.ExternalId].ExternalUserIds, - existingExternalUsersIdDict, - existingGroupUsers.ContainsKey(group.Id) ? existingGroupUsers[group.Id] : null); - } - - await _eventService.LogGroupEventsAsync( - updateGroups.Select(g => (g, EventType.Group_Updated, (EventSystemUser?)eventSystemUser, - (DateTime?)DateTime.UtcNow))); - } - } - - await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.ou, e.e, eventSystemUser, e.d))); - } public async Task DeleteSsoUserAsync(Guid userId, Guid? organizationId) { @@ -1315,35 +918,23 @@ public class OrganizationService : IOrganizationService } } - private async Task UpdateUsersAsync(Group group, HashSet groupUsers, - Dictionary existingUsersIdDict, HashSet existingUsers = null) - { - var availableUsers = groupUsers.Intersect(existingUsersIdDict.Keys); - var users = new HashSet(availableUsers.Select(u => existingUsersIdDict[u])); - if (existingUsers != null && existingUsers.Count == users.Count && users.SetEquals(existingUsers)) - { - return; - } - - await _groupRepository.UpdateUsersAsync(group.Id, users); - } - - private async Task> GetUserDeviceIdsAsync(Guid userId) - { - var devices = await _deviceRepository.GetManyByUserIdAsync(userId); - return devices - .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) - .Select(d => d.Id.ToString()); - } public async Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null) { - await _organizationRepository.ReplaceAsync(org); - await _applicationCacheService.UpsertOrganizationAbilityAsync(org); - - if (orgEvent.HasValue) + try { - await _eventService.LogOrganizationEventAsync(org, orgEvent.Value); + await _organizationRepository.ReplaceAsync(org); + await _applicationCacheService.UpsertOrganizationAbilityAsync(org); + + if (orgEvent.HasValue) + { + await _eventService.LogOrganizationEventAsync(org, orgEvent.Value); + } + } + catch (Exception exception) + { + _logger.LogError(exception, "An error occurred while calling {Method} for Organization ({OrganizationID})", nameof(ReplaceAndUpdateCacheAsync), org.Id); + throw; } } @@ -1587,122 +1178,6 @@ public class OrganizationService : IOrganizationService return true; } - public async Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId) - { - if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId.Value) - { - throw new BadRequestException("You cannot revoke yourself."); - } - - if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue && - !await _currentContext.OrganizationOwner(organizationUser.OrganizationId)) - { - throw new BadRequestException("Only owners can revoke other owners."); - } - - await RepositoryRevokeUserAsync(organizationUser); - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); - - if (organizationUser.UserId.HasValue) - { - await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); - } - } - - public async Task RevokeUserAsync(OrganizationUser organizationUser, - EventSystemUser systemUser) - { - await RepositoryRevokeUserAsync(organizationUser); - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, - systemUser); - - if (organizationUser.UserId.HasValue) - { - await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); - } - } - - private async Task RepositoryRevokeUserAsync(OrganizationUser organizationUser) - { - if (organizationUser.Status == OrganizationUserStatusType.Revoked) - { - throw new BadRequestException("Already revoked."); - } - - if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationUser.OrganizationId, - new[] { organizationUser.Id }, includeProvider: true)) - { - throw new BadRequestException("Organization must have at least one confirmed owner."); - } - - await _organizationUserRepository.RevokeAsync(organizationUser.Id); - organizationUser.Status = OrganizationUserStatusType.Revoked; - } - - public async Task>> RevokeUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? revokingUserId) - { - var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUserIds); - var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) - .ToList(); - - if (!filteredUsers.Any()) - { - throw new BadRequestException("Users invalid."); - } - - if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds)) - { - throw new BadRequestException("Organization must have at least one confirmed owner."); - } - - var deletingUserIsOwner = false; - if (revokingUserId.HasValue) - { - deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId); - } - - var result = new List>(); - - foreach (var organizationUser in filteredUsers) - { - try - { - if (organizationUser.Status == OrganizationUserStatusType.Revoked) - { - throw new BadRequestException("Already revoked."); - } - - if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId) - { - throw new BadRequestException("You cannot revoke yourself."); - } - - if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue && - !deletingUserIsOwner) - { - throw new BadRequestException("Only owners can revoke other owners."); - } - - await _organizationUserRepository.RevokeAsync(organizationUser.Id); - organizationUser.Status = OrganizationUserStatusType.Revoked; - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); - if (organizationUser.UserId.HasValue) - { - await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); - } - - result.Add(Tuple.Create(organizationUser, "")); - } - catch (BadRequestException e) - { - result.Add(Tuple.Create(organizationUser, e.Message)); - } - } - - return result; - } - public static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser) { // Determine status to revert back to @@ -1720,4 +1195,44 @@ public class OrganizationService : IOrganizationService return status; } + + private List> CreateCollectionManagementLoggingActions( + Organization existingOrganization, OrganizationCollectionManagementSettings settings) + { + var loggingActions = new List>(); + + if (existingOrganization.LimitCollectionCreation != settings.LimitCollectionCreation) + { + var eventType = settings.LimitCollectionCreation + ? EventType.Organization_CollectionManagement_LimitCollectionCreationEnabled + : EventType.Organization_CollectionManagement_LimitCollectionCreationDisabled; + loggingActions.Add(() => _eventService.LogOrganizationEventAsync(existingOrganization, eventType)); + } + + if (existingOrganization.LimitCollectionDeletion != settings.LimitCollectionDeletion) + { + var eventType = settings.LimitCollectionDeletion + ? EventType.Organization_CollectionManagement_LimitCollectionDeletionEnabled + : EventType.Organization_CollectionManagement_LimitCollectionDeletionDisabled; + loggingActions.Add(() => _eventService.LogOrganizationEventAsync(existingOrganization, eventType)); + } + + if (existingOrganization.LimitItemDeletion != settings.LimitItemDeletion) + { + var eventType = settings.LimitItemDeletion + ? EventType.Organization_CollectionManagement_LimitItemDeletionEnabled + : EventType.Organization_CollectionManagement_LimitItemDeletionDisabled; + loggingActions.Add(() => _eventService.LogOrganizationEventAsync(existingOrganization, eventType)); + } + + if (existingOrganization.AllowAdminAccessToAllCollectionItems != settings.AllowAdminAccessToAllCollectionItems) + { + var eventType = settings.AllowAdminAccessToAllCollectionItems + ? EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled + : EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled; + loggingActions.Add(() => _eventService.LogOrganizationEventAsync(existingOrganization, eventType)); + } + + return loggingActions; + } } diff --git a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs index d424bd8fff..a83eccc301 100644 --- a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs +++ b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs @@ -1,5 +1,10 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; @@ -16,21 +21,39 @@ public class PolicyService : IPolicyService private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPolicyRepository _policyRepository; private readonly GlobalSettings _globalSettings; + private readonly IFeatureService _featureService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public PolicyService( IApplicationCacheService applicationCacheService, IOrganizationUserRepository organizationUserRepository, IPolicyRepository policyRepository, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery) { _applicationCacheService = applicationCacheService; _organizationUserRepository = organizationUserRepository; _policyRepository = policyRepository; _globalSettings = globalSettings; + _featureService = featureService; + _policyRequirementQuery = policyRequirementQuery; } public async Task GetMasterPasswordPolicyForUserAsync(User user) { + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + var masterPaswordPolicy = (await _policyRequirementQuery.GetAsync(user.Id)); + + if (!masterPaswordPolicy.Enabled) + { + return null; + } + + return masterPaswordPolicy.EnforcedOptions; + } + var policies = (await _policyRepository.GetManyByUserIdAsync(user.Id)) .Where(p => p.Type == PolicyType.MasterPassword && p.Enabled) .ToList(); @@ -48,6 +71,7 @@ public class PolicyService : IPolicyService } return enforcedOptions; + } public async Task> GetPoliciesApplicableToUserAsync(Guid userId, PolicyType policyType, OrganizationUserStatusType minStatus = OrganizationUserStatusType.Accepted) diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs index d96c4a0ce1..6ecea7d234 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Interfaces; +using Bit.Core.Auth.Identity; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; @@ -116,7 +117,7 @@ public class NoopEventService : IEventService return Task.FromResult(0); } - public Task LogServiceAccountSecretEventAsync(Guid serviceAccountId, Secret secret, EventType type, + public Task LogUserSecretsEventAsync(Guid userId, IEnumerable secrets, EventType type, DateTime? date = null) { return Task.FromResult(0); @@ -127,4 +128,31 @@ public class NoopEventService : IEventService { return Task.FromResult(0); } + + public Task LogUserProjectsEventAsync(Guid userId, IEnumerable projects, EventType type, + DateTime? date = null) + { + return Task.FromResult(0); + } + + public Task LogServiceAccountProjectsEventAsync(Guid serviceAccountId, IEnumerable projects, EventType type, + DateTime? date = null) + { + return Task.FromResult(0); + } + + public Task LogServiceAccountPeopleEventAsync(Guid userId, UserServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + return Task.FromResult(0); + } + + public Task LogServiceAccountGroupEventAsync(Guid userId, GroupServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + return Task.FromResult(0); + } + + public Task LogServiceAccountEventAsync(Guid userId, List serviceAccount, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + return Task.FromResult(0); + } } diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs index 94c1096b58..3782b30e3f 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs @@ -1,6 +1,9 @@ -using Bit.Core.AdminConsole.Entities.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -8,7 +11,7 @@ namespace Bit.Core.AdminConsole.Services.NoopImplementations; public class NoopProviderService : IProviderService { - public Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) => throw new NotImplementedException(); + public Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress) => throw new NotImplementedException(); public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException(); diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs index c34c073e87..d6c8d08c4c 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs @@ -19,7 +19,7 @@ public class NoopSlackService : ISlackService return Task.FromResult(string.Empty); } - public string GetRedirectUrl(string redirectUrl) + public string GetRedirectUrl(string callbackUrl, string state) { return string.Empty; } diff --git a/src/Core/AdminConsole/Services/OrganizationFactory.cs b/src/Core/AdminConsole/Services/OrganizationFactory.cs index 3261d89253..afb3931ec4 100644 --- a/src/Core/AdminConsole/Services/OrganizationFactory.cs +++ b/src/Core/AdminConsole/Services/OrganizationFactory.cs @@ -3,9 +3,9 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Business; namespace Bit.Core.AdminConsole.Services; @@ -23,7 +23,7 @@ public static class OrganizationFactory PlanType = claimsPrincipal.GetValue(OrganizationLicenseConstants.PlanType), Seats = claimsPrincipal.GetValue(OrganizationLicenseConstants.Seats), MaxCollections = claimsPrincipal.GetValue(OrganizationLicenseConstants.MaxCollections), - MaxStorageGb = 10240, + MaxStorageGb = Constants.SelfHostedMaxStorageGb, UsePolicies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePolicies), UseSso = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseSso), UseKeyConnector = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseKeyConnector), @@ -75,7 +75,7 @@ public static class OrganizationFactory PlanType = license.PlanType, Seats = license.Seats, MaxCollections = license.MaxCollections, - MaxStorageGb = 10240, + MaxStorageGb = Constants.SelfHostedMaxStorageGb, UsePolicies = license.UsePolicies, UseSso = license.UseSso, UseKeyConnector = license.UseKeyConnector, diff --git a/src/Core/AdminConsole/Utilities/DebuggingInstruments/UserInviteDebuggingLogger.cs b/src/Core/AdminConsole/Utilities/DebuggingInstruments/UserInviteDebuggingLogger.cs new file mode 100644 index 0000000000..6a0e581522 --- /dev/null +++ b/src/Core/AdminConsole/Utilities/DebuggingInstruments/UserInviteDebuggingLogger.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Microsoft.Extensions.Logging; +using Quartz.Util; + +namespace Bit.Core.AdminConsole.Utilities.DebuggingInstruments; + +/// +/// Temporary code: Log warning when OrganizationUser is in an invalid state, +/// so we can identify which flow is causing the issue through Datadog. +/// +public static class UserInviteDebuggingLogger +{ + public static void LogUserInviteStateDiagnostics(this ILogger logger, OrganizationUser orgUser) + { + LogUserInviteStateDiagnostics(logger, [orgUser]); + } + + public static void LogUserInviteStateDiagnostics(this ILogger logger, IEnumerable allOrgUsers) + { + try + { + var invalidInviteState = allOrgUsers.Any(user => user.Status == OrganizationUserStatusType.Invited && user.Email.IsNullOrWhiteSpace()); + + if (invalidInviteState) + { + var logData = MapObjectDataToLog(allOrgUsers); + logger.LogWarning("Warning invalid invited state. {logData}", logData); + } + + var invalidConfirmedOrAcceptedState = allOrgUsers.Any(user => (user.Status == OrganizationUserStatusType.Confirmed || user.Status == OrganizationUserStatusType.Accepted) && !user.Email.IsNullOrWhiteSpace()); + + if (invalidConfirmedOrAcceptedState) + { + var logData = MapObjectDataToLog(allOrgUsers); + logger.LogWarning("Warning invalid confirmed or accepted state. {logData}", logData); + } + } + catch (Exception exception) + { + + // Ensure that this debugging instrument does not interfere with the current flow. + logger.LogWarning(exception, "Unexpected exception from UserInviteDebuggingLogger"); + } + } + + private static string MapObjectDataToLog(IEnumerable allOrgUsers) + { + var log = allOrgUsers.Select(allOrgUser => new + { + allOrgUser.OrganizationId, + allOrgUser.Status, + hasEmail = !allOrgUser.Email.IsNullOrWhiteSpace(), + userId = allOrgUser.UserId, + allOrgUserId = allOrgUser.Id + }); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + return JsonSerializer.Serialize(log, options); + } +} diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs index aab4e448e5..b561e58a86 100644 --- a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -20,7 +20,13 @@ public static partial class IntegrationTemplateProcessor { var propertyName = match.Groups[1].Value; var property = type.GetProperty(propertyName); - return property?.GetValue(values)?.ToString() ?? match.Value; + + if (property == null) + { + return match.Value; // Return unknown keys as keys - i.e. #Key# + } + + return property?.GetValue(values)?.ToString() ?? ""; }); } diff --git a/src/Core/AssemblyInfo.cs b/src/Core/AssemblyInfo.cs index a5edd1a27b..66f5b58ef8 100644 --- a/src/Core/AssemblyInfo.cs +++ b/src/Core/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Core.Test")] +[assembly: InternalsVisibleTo("Identity.IntegrationTest")] diff --git a/src/Core/Auth/Entities/AuthRequest.cs b/src/Core/Auth/Entities/AuthRequest.cs index 088c24b88a..2117c575c0 100644 --- a/src/Core/Auth/Entities/AuthRequest.cs +++ b/src/Core/Auth/Entities/AuthRequest.cs @@ -1,4 +1,8 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; +using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Utilities; @@ -40,11 +44,31 @@ public class AuthRequest : ITableObject public bool IsSpent() { - return ResponseDate.HasValue || AuthenticationDate.HasValue || GetExpirationDate() < DateTime.UtcNow; + return ResponseDate.HasValue || AuthenticationDate.HasValue || IsExpired(); + } + + public bool IsExpired() + { + // TODO: PM-24252 - consider using TimeProvider for better mocking in tests + return GetExpirationDate() < DateTime.UtcNow; + } + + // TODO: PM-24252 - this probably belongs in a service. + public bool IsValidForAuthentication(Guid userId, + string password) + { + return ResponseDate.HasValue // it’s been responded to + && Approved == true // it was approved + && !IsExpired() // it's not expired + && Type == AuthRequestType.AuthenticateAndUnlock // it’s an authN request + && !AuthenticationDate.HasValue // it was not already used for authN + && UserId == userId // it belongs to the user + && CoreHelpers.FixedTimeEquals(AccessCode, password); // the access code matches the password } public DateTime GetExpirationDate() { + // TODO: PM-24252 - this should reference PasswordlessAuthSettings.UserRequestExpiration return CreationDate.AddMinutes(15); } } diff --git a/src/Core/Auth/Entities/EmergencyAccess.cs b/src/Core/Auth/Entities/EmergencyAccess.cs index f295f25604..d855126468 100644 --- a/src/Core/Auth/Entities/EmergencyAccess.cs +++ b/src/Core/Auth/Entities/EmergencyAccess.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Utilities; diff --git a/src/Core/Auth/Entities/SsoConfig.cs b/src/Core/Auth/Entities/SsoConfig.cs index c872928031..bbe5e87962 100644 --- a/src/Core/Auth/Entities/SsoConfig.cs +++ b/src/Core/Auth/Entities/SsoConfig.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; namespace Bit.Core.Auth.Entities; diff --git a/src/Core/Auth/Entities/SsoUser.cs b/src/Core/Auth/Entities/SsoUser.cs index 2e457afbc6..eb3250f310 100644 --- a/src/Core/Auth/Entities/SsoUser.cs +++ b/src/Core/Auth/Entities/SsoUser.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; namespace Bit.Core.Auth.Entities; diff --git a/src/Core/Auth/Entities/WebAuthnCredential.cs b/src/Core/Auth/Entities/WebAuthnCredential.cs index 486fd41e3f..595ecfc041 100644 --- a/src/Core/Auth/Entities/WebAuthnCredential.cs +++ b/src/Core/Auth/Entities/WebAuthnCredential.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Utilities; @@ -19,13 +22,30 @@ public class WebAuthnCredential : ITableObject [MaxLength(20)] public string Type { get; set; } public Guid AaGuid { get; set; } + + /// + /// User key encrypted with this WebAuthn credential's public key (EncryptedPublicKey field). + /// [MaxLength(2000)] public string EncryptedUserKey { get; set; } + + /// + /// Private key encrypted with an external key for secure storage. + /// [MaxLength(2000)] public string EncryptedPrivateKey { get; set; } + + /// + /// Public key encrypted with the user key for key rotation. + /// [MaxLength(2000)] public string EncryptedPublicKey { get; set; } + + /// + /// Indicates whether this credential supports PRF (Pseudo-Random Function) extension. + /// public bool SupportsPrf { get; set; } + public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; diff --git a/src/Core/Auth/Enums/TwoFactorEmailPurpose.cs b/src/Core/Auth/Enums/TwoFactorEmailPurpose.cs new file mode 100644 index 0000000000..47ac2e0e3c --- /dev/null +++ b/src/Core/Auth/Enums/TwoFactorEmailPurpose.cs @@ -0,0 +1,8 @@ +namespace Core.Auth.Enums; + +public enum TwoFactorEmailPurpose +{ + Login, + Setup, + NewDeviceVerification, +} diff --git a/src/Core/Identity/Claims.cs b/src/Core/Auth/Identity/Claims.cs similarity index 89% rename from src/Core/Identity/Claims.cs rename to src/Core/Auth/Identity/Claims.cs index fad7b37b5f..ac78e987ae 100644 --- a/src/Core/Identity/Claims.cs +++ b/src/Core/Auth/Identity/Claims.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Identity; +namespace Bit.Core.Auth.Identity; public static class Claims { @@ -39,4 +39,9 @@ public static class Claims public const string ManageResetPassword = "manageresetpassword"; public const string ManageScim = "managescim"; } + public static class SendAccessClaims + { + public const string SendId = "send_id"; + public const string Email = "send_email"; + } } diff --git a/src/Core/Identity/CustomIdentityServiceCollectionExtensions.cs b/src/Core/Auth/Identity/CustomIdentityServiceCollectionExtensions.cs similarity index 94% rename from src/Core/Identity/CustomIdentityServiceCollectionExtensions.cs rename to src/Core/Auth/Identity/CustomIdentityServiceCollectionExtensions.cs index 01914540ac..f313e8995c 100644 --- a/src/Core/Identity/CustomIdentityServiceCollectionExtensions.cs +++ b/src/Core/Auth/Identity/CustomIdentityServiceCollectionExtensions.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Identity; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Identity; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/src/Core/Identity/IdentityClientType.cs b/src/Core/Auth/Identity/IdentityClientType.cs similarity index 67% rename from src/Core/Identity/IdentityClientType.cs rename to src/Core/Auth/Identity/IdentityClientType.cs index bd5b68ff6f..113877135d 100644 --- a/src/Core/Identity/IdentityClientType.cs +++ b/src/Core/Auth/Identity/IdentityClientType.cs @@ -1,8 +1,9 @@ -namespace Bit.Core.Identity; +namespace Bit.Core.Auth.Identity; public enum IdentityClientType : byte { User = 0, Organization = 1, ServiceAccount = 2, + Send = 3 } diff --git a/src/Core/Auth/Identity/LowerInvariantLookupNormalizer.cs b/src/Core/Auth/Identity/LowerInvariantLookupNormalizer.cs index 1318d94760..7f058ed5d4 100644 --- a/src/Core/Auth/Identity/LowerInvariantLookupNormalizer.cs +++ b/src/Core/Auth/Identity/LowerInvariantLookupNormalizer.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Identity; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.Identity; diff --git a/src/Core/Auth/Identity/Policies.cs b/src/Core/Auth/Identity/Policies.cs new file mode 100644 index 0000000000..b2d94b0a6e --- /dev/null +++ b/src/Core/Auth/Identity/Policies.cs @@ -0,0 +1,16 @@ +namespace Bit.Core.Auth.Identity; + +public static class Policies +{ + /// + /// Policy for managing access to the Send feature. + /// + public const string Send = "Send"; // [Authorize(Policy = Policies.Send)] + public const string Application = "Application"; // [Authorize(Policy = Policies.Application)] + public const string Web = "Web"; // [Authorize(Policy = Policies.Web)] + public const string Push = "Push"; // [Authorize(Policy = Policies.Push)] + public const string Licensing = "Licensing"; // [Authorize(Policy = Policies.Licensing)] + public const string Organization = "Organization"; // [Authorize(Policy = Policies.Organization)] + public const string Installation = "Installation"; // [Authorize(Policy = Policies.Installation)] + public const string Secrets = "Secrets"; // [Authorize(Policy = Policies.Secrets)] +} diff --git a/src/Core/Auth/Identity/RoleStore.cs b/src/Core/Auth/Identity/RoleStore.cs index 3ea530dd04..388f904e71 100644 --- a/src/Core/Auth/Identity/RoleStore.cs +++ b/src/Core/Auth/Identity/RoleStore.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.Identity; diff --git a/src/Core/Auth/Identity/TokenProviders/AuthenticatorTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/AuthenticatorTokenProvider.cs index 5a3d9522f3..6348d6f27b 100644 --- a/src/Core/Auth/Identity/TokenProviders/AuthenticatorTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/AuthenticatorTokenProvider.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; diff --git a/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenProvider.cs index 3f2a44915c..6ed715b14b 100644 --- a/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenProvider.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Entities; diff --git a/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenService.cs b/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenService.cs index 8dd07e7ee6..a59a76de0a 100644 --- a/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenService.cs +++ b/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenService.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; using Bit.Core.Entities; diff --git a/src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs index be94124c03..70aba8ef75 100644 --- a/src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs @@ -1,5 +1,6 @@ using System.Text; using Bit.Core.Entities; +using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; @@ -7,6 +8,9 @@ using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Auth.Identity.TokenProviders; +/// +/// Generates and validates tokens for email OTPs. +/// public class EmailTokenProvider : IUserTwoFactorTokenProvider { private const string CacheKeyFormat = "EmailToken_{0}_{1}_{2}"; @@ -16,16 +20,25 @@ public class EmailTokenProvider : IUserTwoFactorTokenProvider public EmailTokenProvider( [FromKeyedServices("persistent")] - IDistributedCache distributedCache) + IDistributedCache distributedCache, + IFeatureService featureService) { _distributedCache = distributedCache; _distributedCacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }; + if (featureService.IsEnabled(FeatureFlagKeys.Otp6Digits)) + { + TokenLength = 6; + } + else + { + TokenLength = 8; + } } - public int TokenLength { get; protected set; } = 8; + public int TokenLength { get; protected set; } public bool TokenAlpha { get; protected set; } = false; public bool TokenNumeric { get; protected set; } = true; diff --git a/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs index 2f8481cea2..2d72781569 100644 --- a/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs @@ -1,19 +1,30 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Entities; +using Bit.Core.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Auth.Identity.TokenProviders; +/// +/// Generates tokens for email two-factor authentication. +/// It inherits from the EmailTokenProvider class, which manages the persistence and validation of tokens, +/// and adds additional validation to ensure that 2FA is enabled for the user. +/// public class EmailTwoFactorTokenProvider : EmailTokenProvider { public EmailTwoFactorTokenProvider( [FromKeyedServices("persistent")] - IDistributedCache distributedCache) : - base(distributedCache) + IDistributedCache distributedCache, + IFeatureService featureService) : + base(distributedCache, featureService) { + // This can be removed when the pm-18612-otp-6-digits feature flag is removed because the base implementation will match. TokenAlpha = false; TokenNumeric = true; TokenLength = 6; diff --git a/src/Core/Auth/Identity/TokenProviders/OrganizationDuoUniversalTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/OrganizationDuoUniversalTokenProvider.cs index c8007dd6ec..07768c32c9 100644 --- a/src/Core/Auth/Identity/TokenProviders/OrganizationDuoUniversalTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/OrganizationDuoUniversalTokenProvider.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; diff --git a/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/IOtpTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/IOtpTokenProvider.cs new file mode 100644 index 0000000000..bf153e80eb --- /dev/null +++ b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/IOtpTokenProvider.cs @@ -0,0 +1,29 @@ +namespace Bit.Core.Auth.Identity.TokenProviders; + +/// +/// A generic interface for a one-time password (OTP) token provider. +/// +public interface IOtpTokenProvider + where TOptions : DefaultOtpTokenProviderOptions +{ + /// + /// Generates a new one-time password (OTP) based on the configured parameters. + /// The generated OTP is stored in the distributed cache with a key based on the unique identifier and purpose. If the + /// key is already in use, it will overwrite and generate a new OTP with a refreshed TTL. + /// + /// Name of the token provider, used to distinguish different token providers that may inject this class + /// Purpose of the OTP token, used to distinguish different types of tokens. + /// Unique identifier to distinguish one request from another + /// generated token | null + Task GenerateTokenAsync(string tokenProviderName, string purpose, string uniqueIdentifier); + + /// + /// Validates the provided token against the stored value in the distributed cache. + /// + /// string value matched against the unique identifier in the cache if found + /// Name of the token provider, used to distinguish different token providers that may inject this class + /// Purpose of the OTP token, used to distinguish different types of tokens. + /// Unique identifier to distinguish one request from another + /// true if the token matches what is fetched from the cache, false if not. + Task ValidateTokenAsync(string token, string tokenProviderName, string purpose, string uniqueIdentifier); +} diff --git a/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProvider.cs new file mode 100644 index 0000000000..b6280e13fe --- /dev/null +++ b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProvider.cs @@ -0,0 +1,75 @@ +using System.Text; +using Bit.Core.Utilities; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bit.Core.Auth.Identity.TokenProviders; + +public class OtpTokenProvider( + [FromKeyedServices("persistent")] + IDistributedCache distributedCache, + IOptions options) : IOtpTokenProvider + where TOptions : DefaultOtpTokenProviderOptions +{ + private readonly TOptions _otpTokenProviderOptions = options.Value; + + /// + /// This is where the OTP tokens are stored. + /// + private readonly IDistributedCache _distributedCache = distributedCache; + + /// + /// Used to store and fetch the OTP tokens from the distributed cache. + /// The format is "{tokenProviderName}_{purpose}_{uniqueIdentifier}". + /// + private readonly string _cacheKeyFormat = "{0}_{1}_{2}"; + + public async Task GenerateTokenAsync(string tokenProviderName, string purpose, string uniqueIdentifier) + { + if (string.IsNullOrEmpty(tokenProviderName) + || string.IsNullOrEmpty(purpose) + || string.IsNullOrEmpty(uniqueIdentifier)) + { + return null; + } + + var cacheKey = string.Format(_cacheKeyFormat, tokenProviderName, purpose, uniqueIdentifier); + var token = CoreHelpers.SecureRandomString( + _otpTokenProviderOptions.TokenLength, + _otpTokenProviderOptions.TokenAlpha, + true, + false, + _otpTokenProviderOptions.TokenNumeric, + false); + await _distributedCache.SetAsync(cacheKey, Encoding.UTF8.GetBytes(token), _otpTokenProviderOptions.DistributedCacheEntryOptions); + return token; + } + + public async Task ValidateTokenAsync(string token, string tokenProviderName, string purpose, string uniqueIdentifier) + { + if (string.IsNullOrEmpty(token) + || string.IsNullOrEmpty(tokenProviderName) + || string.IsNullOrEmpty(purpose) + || string.IsNullOrEmpty(uniqueIdentifier)) + { + return false; + } + + var cacheKey = string.Format(_cacheKeyFormat, tokenProviderName, purpose, uniqueIdentifier); + var cachedValue = await _distributedCache.GetAsync(cacheKey); + if (cachedValue == null) + { + return false; + } + + var code = Encoding.UTF8.GetString(cachedValue); + var valid = string.Equals(token, code); + if (valid) + { + await _distributedCache.RemoveAsync(cacheKey); + } + + return valid; + } +} diff --git a/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProviderOptions.cs b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProviderOptions.cs new file mode 100644 index 0000000000..95996d69a6 --- /dev/null +++ b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProviderOptions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Caching.Distributed; + +namespace Bit.Core.Auth.Identity.TokenProviders; + +/// +/// Options for configuring the OTP token provider. +/// +public class DefaultOtpTokenProviderOptions +{ + /// + /// Gets or sets the length of the generated token. + /// Default is 6 characters. + /// + public int TokenLength { get; set; } = 6; + + /// + /// Gets or sets whether the token should contain alphabetic characters. + /// Default is false. + /// + public bool TokenAlpha { get; set; } = false; + + /// + /// Gets or sets whether the token should contain numeric characters. + /// Default is true. + /// + public bool TokenNumeric { get; set; } = true; + + /// + /// Cache entry options for Otp Token provider. + /// Default is 5 minutes expiration. + /// + public DistributedCacheEntryOptions DistributedCacheEntryOptions { get; set; } = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) + }; +} diff --git a/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/readme.md b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/readme.md new file mode 100644 index 0000000000..8cf12a98bf --- /dev/null +++ b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/readme.md @@ -0,0 +1,206 @@ +# OtpTokenProvider + +The `OtpTokenProvider` is a token provider service for generating and validating Time-Based one-time passwords (TOTP). It provides a secure way to create temporary tokens for various authentication and verification scenarios. The provider can be configured to generate tokens specific to your use case by using the options pattern in the DI pipeline. + +## Overview + +The OTP Token Provider generates secure, time-limited tokens that can be used for: + +- Two-factor authentication +- Temporary access tokens for Sends +- Any scenario requiring short-lived verification codes + +## Features + +- **Configurable Token Length**: Default 6 characters, customizable +- **Character Set Options**: Numeric (default), alphabetic, or mixed +- **Distributed Caching**: Uses CosmosDb for cloud, or the configured database otherwise. +- **TTL Management**: Configurable expiration (default 5 minutes) +- **Secure Generation**: Uses cryptographically secure random generation +- **One-Time Use**: Tokens are automatically deleted from the cache after successful validation + +## Architecture + +### Interface: `IOtpTokenProvider` + +```csharp +public interface IOtpTokenProvider + where TOptions : DefaultOtpTokenProviderOptions +{ + Task GenerateTokenAsync(string tokenProviderName, string purpose, string uniqueIdentifier); + Task ValidateTokenAsync(string token, string tokenProviderName, string purpose, string uniqueIdentifier); +} +``` + +### Implementation: `OtpTokenProvider` + +The provider is initialized with: + +- **Distributed Cache**: Storage backend for tokens (using "persistent" keyed service) +- **IOptions**: Configuration options for token generation and caching + +## Usage + +### Basic Setup + +If your class needs the use the `IOtpTokenProvider` you can inject it like any other injectable class from the DI. + +### Generating a Token + +```csharp +// Generate a new OTP with token provider name, purpose and unique identifier +string token = await otpProvider.GenerateTokenAsync("EmailToken", "email_verification", $"{userId}_{securityStamp}"); +// Returns: "123456" (6-digit numeric by default) +``` + +### Validating a Token + +```csharp +// Validate user-provided token with same parameters used for generation +bool isValid = await otpProvider.ValidateTokenAsync("123456", "EmailToken", "email_verification", $"{userId}_{securityStamp}"); +// Returns: true if valid, false otherwise +// Note: Valid tokens are automatically removed from cache +``` + +### Custom Configurations + +If you need to modify the default options you can do so by creating an extension of the `DefaultOtpTokenProviderOptions` and using that class as the TOptions when injecting another IOtpTokenProvider service. + +#### OtpTokenProviderOptions + +```csharp +public class DefaultOtpTokenProviderOptions +{ ... } + +public class UserEmailOtpTokenOptions : DefaultOtpTokenProviderOptions { } +``` + +#### Service Collection + +```csharp +public static IdentityBuilder AddCustomIdentityServices( + this IServiceCollection services, GlobalSettings globalSettings) +{ + // possible customization + services.Configure(options => + { + options.TokenLength = 8; + // The other options are left default + }); + + // TryAddTransient open generics -> this allows us to inject IOtpTokenProvider without having to specify the specific type here. + services.TryAddTransient(typeof(IOtpTokenProvider<>), typeof(OtpTokenProvider<>); +} +``` + +#### Usage + +```csharp +public class UserEmailTokenProvider( + IOtpTokenProvider otpTokenProvider +) +{ + private readonly IOtpTokenProvider _otpTokenProvider = otpTokenProvider; + ... +} +``` + +## Configuration Options + +### Token Properties + +| Property | Default | Description | +| -------------- | ------- | ---------------------------------------- | +| `TokenLength` | 6 | Number of characters in generated token | +| `TokenAlpha` | false | Include alphabetic characters (a-z, A-Z) | +| `TokenNumeric` | true | Include numeric characters (0-9) | + +### Cache Options + +See `DistributedCacheEntryOptions` documentation for a complete list of configuration options. + +| Property | Default | Description | +| --------------------------------- | --------- | ---------------------------- | +| `AbsoluteExpirationRelativeToNow` | 5 minutes | How long tokens remain valid | + +## Cache Key Format + +The cache key format uses three components: `{tokenProviderName}_{purpose}_{uniqueIdentifier}` + +### Examples: + +#### Possible Email Token Provider Example + +Email token provider uses: + +- **Token Provider Name**: `"EmailToken"` (identifies the specific use case) +- **Purpose**: `"EmailTwoFactorAuthentication"` (specific action being verified) +- **Unique Identifier**: `"{user.Id}_{securityStamp}"` (user-specific data) + +These are passed into the OTP Token Provider which creates a cache record: + +- Cache Key: `EmailToken_EmailTwoFactorAuthentication_guid_guid` + +## Security Considerations + +### Token Generation + +- Uses `CoreHelpers.SecureRandomString()` for cryptographically secure randomness +- No predictable patterns in generated tokens +- Configurable character sets for different security requirements + +### Storage + +- Tokens are stored in distributed cache. The cache depends on the specific deployment, for cloud it is CosmosDb. +- Automatic expiration prevents indefinite token validity +- One-time use prevents replay attacks + +### Validation + +- Exact string matching for validation +- Automatic removal after successful validation +- Returns `false` for expired or non-existent tokens + +## Dependency Injection + +The provider is registered in `ServiceCollectionExtensions.cs`: + +```csharp +services.TryAddScoped, OtpTokenProvider>(); +``` + +## Error Handling + +### Common Scenarios + +- **Token Not Found**: `ValidateTokenAsync()` returns `false` +- **Token Expired**: Automatically cleaned up by cache, validation returns `false` +- **Invalid Input**: + - `GenerateTokenAsync` returns `null` for empty/null tokenProviderName, purpose, or uniqueIdentifier + - `ValidateTokenAsync` returns `false` for empty/null token, tokenProviderName, purpose, or uniqueIdentifier + - No cache operations are performed for invalid inputs + +### Best Practices + +- Always check validation results +- Handle token expiration gracefully +- Provide clear user feedback for invalid tokens +- Implement rate limiting for token generation + +## Related Components + +- **`CoreHelpers.SecureRandomString()`**: Secure token generation +- **`IDistributedCache`**: Token storage backend +- **Two-Factor Authentication Providers**: Integration with 2FA flows +- **Email Services**: A Token delivery mechanism + +## Testing + +When testing components that use `OtpTokenProvider`: + +```csharp +// Mock the interface for unit tests +var mockOtpProvider = Substitute.For>(); +mockOtpProvider.GenerateTokenAsync("EmailToken", "email_verification", "user_123").Returns("123456"); +mockOtpProvider.ValidateTokenAsync("123456", "EmailToken", "email_verification", "user_123").Returns(true); +``` diff --git a/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs index 3b4b0fa520..60fb2c5635 100644 --- a/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Entities; diff --git a/src/Core/Auth/Identity/TokenProviders/YubicoOtpTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/YubicoOtpTokenProvider.cs index b33d2fc0c9..ddac1843ec 100644 --- a/src/Core/Auth/Identity/TokenProviders/YubicoOtpTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/YubicoOtpTokenProvider.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Services; using Bit.Core.Settings; diff --git a/src/Core/Auth/Identity/UserStore.cs b/src/Core/Auth/Identity/UserStore.cs index 41323f05b7..e8ae95a0bd 100644 --- a/src/Core/Auth/Identity/UserStore.cs +++ b/src/Core/Auth/Identity/UserStore.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Repositories; diff --git a/src/Core/IdentityServer/ApiScopes.cs b/src/Core/Auth/IdentityServer/ApiScopes.cs similarity index 85% rename from src/Core/IdentityServer/ApiScopes.cs rename to src/Core/Auth/IdentityServer/ApiScopes.cs index 6e3ce0d140..8836a168b6 100644 --- a/src/Core/IdentityServer/ApiScopes.cs +++ b/src/Core/Auth/IdentityServer/ApiScopes.cs @@ -1,6 +1,6 @@ using Duende.IdentityServer.Models; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public static class ApiScopes { @@ -11,6 +11,7 @@ public static class ApiScopes public const string ApiPush = "api.push"; public const string ApiSecrets = "api.secrets"; public const string Internal = "internal"; + public const string ApiSendAccess = "api.send.access"; public static IEnumerable GetApiScopes() { @@ -23,6 +24,7 @@ public static class ApiScopes new(ApiInstallation, "API Installation Access"), new(Internal, "Internal Access"), new(ApiSecrets, "Secrets Manager Access"), + new(ApiSendAccess, "API Send Access"), }; } } diff --git a/src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs b/src/Core/Auth/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs similarity index 90% rename from src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs rename to src/Core/Auth/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs index cbb91a1e72..5319539050 100644 --- a/src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs +++ b/src/Core/Auth/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs @@ -1,11 +1,14 @@ -using Duende.IdentityServer.Configuration; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Duende.IdentityServer.Configuration; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public class ConfigureOpenIdConnectDistributedOptions : IPostConfigureOptions { diff --git a/src/Core/IdentityServer/DistributedCacheCookieManager.cs b/src/Core/Auth/IdentityServer/DistributedCacheCookieManager.cs similarity index 93% rename from src/Core/IdentityServer/DistributedCacheCookieManager.cs rename to src/Core/Auth/IdentityServer/DistributedCacheCookieManager.cs index 5d6717ac41..138aeaf7e8 100644 --- a/src/Core/IdentityServer/DistributedCacheCookieManager.cs +++ b/src/Core/Auth/IdentityServer/DistributedCacheCookieManager.cs @@ -1,10 +1,13 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public class DistributedCacheCookieManager : ICookieManager { diff --git a/src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs b/src/Core/Auth/IdentityServer/DistributedCacheTicketDataFormatter.cs similarity index 92% rename from src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs rename to src/Core/Auth/IdentityServer/DistributedCacheTicketDataFormatter.cs index 6a4b7439d4..565d02a838 100644 --- a/src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs +++ b/src/Core/Auth/IdentityServer/DistributedCacheTicketDataFormatter.cs @@ -1,8 +1,11 @@ -using Microsoft.AspNetCore.Authentication; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Distributed; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public class DistributedCacheTicketDataFormatter : ISecureDataFormat { diff --git a/src/Core/IdentityServer/DistributedCacheTicketStore.cs b/src/Core/Auth/IdentityServer/DistributedCacheTicketStore.cs similarity index 90% rename from src/Core/IdentityServer/DistributedCacheTicketStore.cs rename to src/Core/Auth/IdentityServer/DistributedCacheTicketStore.cs index 949c1173cc..675b0cd7a5 100644 --- a/src/Core/IdentityServer/DistributedCacheTicketStore.cs +++ b/src/Core/Auth/IdentityServer/DistributedCacheTicketStore.cs @@ -1,8 +1,11 @@ -using Microsoft.AspNetCore.Authentication; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Caching.Distributed; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public class DistributedCacheTicketStore : ITicketStore { diff --git a/src/Core/Auth/IdentityServer/TokenRetrieval.cs b/src/Core/Auth/IdentityServer/TokenRetrieval.cs index 36c23506cb..bf0230bafb 100644 --- a/src/Core/Auth/IdentityServer/TokenRetrieval.cs +++ b/src/Core/Auth/IdentityServer/TokenRetrieval.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Http; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Http; namespace Bit.Core.Auth.IdentityServer; diff --git a/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs index 0964fe1a1d..f89b67f3c5 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Utilities; diff --git a/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestCreateRequestModel.cs b/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestCreateRequestModel.cs index e7cd05be20..7fbc5f19b1 100644 --- a/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestCreateRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Enums; namespace Bit.Core.Auth.Models.Api.Request.AuthRequest; diff --git a/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestUpdateRequestModel.cs b/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestUpdateRequestModel.cs index 1577f3a1c8..c834ec8e55 100644 --- a/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestUpdateRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Auth.Models.Api.Request.AuthRequest; diff --git a/src/Core/Auth/Models/Api/Request/DeviceKeysUpdateRequestModel.cs b/src/Core/Auth/Models/Api/Request/DeviceKeysUpdateRequestModel.cs index 111b03a3a3..bcd648d1fb 100644 --- a/src/Core/Auth/Models/Api/Request/DeviceKeysUpdateRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/DeviceKeysUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Utilities; diff --git a/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs b/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs index 6a0641246b..6c323d6207 100644 --- a/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs +++ b/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs @@ -1,4 +1,7 @@ - +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + + using Bit.Core.Models.Api; using Fido2NetLib; diff --git a/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs b/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs index 3e3076e84e..47a308b28d 100644 --- a/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs +++ b/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models.Data; using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Utilities; diff --git a/src/Core/Auth/Models/Api/Response/ProtectedDeviceResponseModel.cs b/src/Core/Auth/Models/Api/Response/ProtectedDeviceResponseModel.cs index c64c552977..c2fff4afee 100644 --- a/src/Core/Auth/Models/Api/Response/ProtectedDeviceResponseModel.cs +++ b/src/Core/Auth/Models/Api/Response/ProtectedDeviceResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; diff --git a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs index b5f2b77cfb..bd8542e8bf 100644 --- a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs +++ b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs @@ -1,8 +1,7 @@ using System.Text.Json.Serialization; +using Bit.Core.KeyManagement.Models.Response; using Bit.Core.Models.Api; -#nullable enable - namespace Bit.Core.Auth.Models.Api.Response; public class UserDecryptionOptions : ResponseModel @@ -14,8 +13,15 @@ public class UserDecryptionOptions : ResponseModel /// /// Gets or sets whether the current user has a master password that can be used to decrypt their vault. /// + [Obsolete("Use MasterPasswordUnlock instead. This will be removed in a future version.")] public bool HasMasterPassword { get; set; } + /// + /// Gets or sets whether the current user has master password unlock data available. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MasterPasswordUnlockResponseModel? MasterPasswordUnlock { get; set; } + /// /// Gets or sets the WebAuthn PRF decryption keys. /// diff --git a/src/Core/Auth/Models/Business/Tokenables/EmergencyAccessInviteTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/EmergencyAccessInviteTokenable.cs index a29afdf1fb..8e8cc41653 100644 --- a/src/Core/Auth/Models/Business/Tokenables/EmergencyAccessInviteTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/EmergencyAccessInviteTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Auth.Entities; namespace Bit.Core.Auth.Models.Business.Tokenables; diff --git a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs index 95c84ad3b5..f04a1181c4 100644 --- a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Entities; using Bit.Core.Tokens; diff --git a/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs index 006da70080..03fcb9b5c0 100644 --- a/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Tokens; namespace Bit.Core.Auth.Models.Business.Tokenables; diff --git a/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs index 30687a6a4a..eeffe0bedc 100644 --- a/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Entities; using Bit.Core.Tokens; diff --git a/src/Core/Auth/Models/Business/Tokenables/SsoTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/SsoTokenable.cs index 48386c5439..06e2dda3d9 100644 --- a/src/Core/Auth/Models/Business/Tokenables/SsoTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/SsoTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities; using Bit.Core.Tokens; diff --git a/src/Core/Auth/Models/Business/Tokenables/TwoFactorAuthenticatorUserVerificationTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/TwoFactorAuthenticatorUserVerificationTokenable.cs index 70a94f5928..76e54374e0 100644 --- a/src/Core/Auth/Models/Business/Tokenables/TwoFactorAuthenticatorUserVerificationTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/TwoFactorAuthenticatorUserVerificationTokenable.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Tokens; using Newtonsoft.Json; diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs index e64edace45..049681a028 100644 --- a/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Entities; using Bit.Core.Tokens; using Fido2NetLib; diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs index 017033b00a..bbea66a6b1 100644 --- a/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Auth.Enums; using Bit.Core.Tokens; using Fido2NetLib; diff --git a/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs b/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs index 15ccad9cb1..03661c7276 100644 --- a/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs +++ b/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Entities; namespace Bit.Core.Auth.Models.Data; diff --git a/src/Core/Auth/Models/Data/EmergencyAccessNotify.cs b/src/Core/Auth/Models/Data/EmergencyAccessNotify.cs index f3f1347338..1c0d4bfe8b 100644 --- a/src/Core/Auth/Models/Data/EmergencyAccessNotify.cs +++ b/src/Core/Auth/Models/Data/EmergencyAccessNotify.cs @@ -1,4 +1,7 @@ - +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + + using Bit.Core.Auth.Entities; namespace Bit.Core.Auth.Models.Data; diff --git a/src/Core/Auth/Models/Data/EmergencyAccessViewData.cs b/src/Core/Auth/Models/Data/EmergencyAccessViewData.cs index 70130e0fcf..1e5916d6af 100644 --- a/src/Core/Auth/Models/Data/EmergencyAccessViewData.cs +++ b/src/Core/Auth/Models/Data/EmergencyAccessViewData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Entities; using Bit.Core.Vault.Models.Data; namespace Bit.Core.Auth.Models.Data; diff --git a/src/Core/Auth/Models/Data/GrantItem.cs b/src/Core/Auth/Models/Data/GrantItem.cs index de856904db..6bf99c019b 100644 --- a/src/Core/Auth/Models/Data/GrantItem.cs +++ b/src/Core/Auth/Models/Data/GrantItem.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Auth.Repositories.Cosmos; using Duende.IdentityServer.Models; diff --git a/src/Core/Auth/Models/Data/IGrant.cs b/src/Core/Auth/Models/Data/IGrant.cs index 5f14631533..1465194a66 100644 --- a/src/Core/Auth/Models/Data/IGrant.cs +++ b/src/Core/Auth/Models/Data/IGrant.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Auth.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Auth.Models.Data; public interface IGrant { diff --git a/src/Core/Auth/Models/Data/OrganizationAdminAuthRequest.cs b/src/Core/Auth/Models/Data/OrganizationAdminAuthRequest.cs index cd3a98efcc..297f2d0120 100644 --- a/src/Core/Auth/Models/Data/OrganizationAdminAuthRequest.cs +++ b/src/Core/Auth/Models/Data/OrganizationAdminAuthRequest.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; namespace Bit.Core.Auth.Models.Data; diff --git a/src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs b/src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs new file mode 100644 index 0000000000..0755e941b7 --- /dev/null +++ b/src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs @@ -0,0 +1,83 @@ + +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Enums; + +namespace Bit.Core.Auth.Models.Data; + +public class PendingAuthRequestDetails : AuthRequest +{ + public Guid? RequestDeviceId { get; set; } + + /** + * Constructor for EF response. + */ + public PendingAuthRequestDetails( + AuthRequest authRequest, + Guid? deviceId) + { + ArgumentNullException.ThrowIfNull(authRequest); + + Id = authRequest.Id; + UserId = authRequest.UserId; + OrganizationId = authRequest.OrganizationId; + Type = authRequest.Type; + RequestDeviceIdentifier = authRequest.RequestDeviceIdentifier; + RequestDeviceType = authRequest.RequestDeviceType; + RequestIpAddress = authRequest.RequestIpAddress; + RequestCountryName = authRequest.RequestCountryName; + ResponseDeviceId = authRequest.ResponseDeviceId; + AccessCode = authRequest.AccessCode; + PublicKey = authRequest.PublicKey; + Key = authRequest.Key; + MasterPasswordHash = authRequest.MasterPasswordHash; + Approved = authRequest.Approved; + CreationDate = authRequest.CreationDate; + ResponseDate = authRequest.ResponseDate; + AuthenticationDate = authRequest.AuthenticationDate; + RequestDeviceId = deviceId; + } + + /** + * Constructor for dapper response. + */ + public PendingAuthRequestDetails( + Guid id, + Guid userId, + Guid organizationId, + short type, + string requestDeviceIdentifier, + short requestDeviceType, + string requestIpAddress, + string requestCountryName, + Guid? responseDeviceId, + string accessCode, + string publicKey, + string key, + string masterPasswordHash, + bool? approved, + DateTime creationDate, + DateTime? responseDate, + DateTime? authenticationDate, + Guid deviceId) + { + Id = id; + UserId = userId; + OrganizationId = organizationId; + Type = (AuthRequestType)type; + RequestDeviceIdentifier = requestDeviceIdentifier; + RequestDeviceType = (DeviceType)requestDeviceType; + RequestIpAddress = requestIpAddress; + RequestCountryName = requestCountryName; + ResponseDeviceId = responseDeviceId; + AccessCode = accessCode; + PublicKey = publicKey; + Key = key; + MasterPasswordHash = masterPasswordHash; + Approved = approved; + CreationDate = creationDate; + ResponseDate = responseDate; + AuthenticationDate = authenticationDate; + RequestDeviceId = deviceId; + } +} diff --git a/src/Core/Auth/Models/Data/SsoConfigurationData.cs b/src/Core/Auth/Models/Data/SsoConfigurationData.cs index fe39a5a054..e4ff7af729 100644 --- a/src/Core/Auth/Models/Data/SsoConfigurationData.cs +++ b/src/Core/Auth/Models/Data/SsoConfigurationData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authentication.OpenIdConnect; diff --git a/src/Core/Auth/Models/Data/WebAuthnLoginRotateKeyData.cs b/src/Core/Auth/Models/Data/WebAuthnLoginRotateKeyData.cs index 40a096c474..5004d35e03 100644 --- a/src/Core/Auth/Models/Data/WebAuthnLoginRotateKeyData.cs +++ b/src/Core/Auth/Models/Data/WebAuthnLoginRotateKeyData.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; namespace Bit.Core.Auth.Models.Data; diff --git a/src/Core/Auth/Models/ITwoFactorProvidersUser.cs b/src/Core/Auth/Models/ITwoFactorProvidersUser.cs index f953e4570e..5cf137b76f 100644 --- a/src/Core/Auth/Models/ITwoFactorProvidersUser.cs +++ b/src/Core/Auth/Models/ITwoFactorProvidersUser.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Services; namespace Bit.Core.Auth.Models; diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessAcceptedViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessAcceptedViewModel.cs index afe29b9843..cbe6dbec1c 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessAcceptedViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessAcceptedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessAcceptedViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessApprovedViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessApprovedViewModel.cs index 9ad446aab6..65d80c06cb 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessApprovedViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessApprovedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessApprovedViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessConfirmedViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessConfirmedViewModel.cs index 2ab55a05eb..4527dfddb0 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessConfirmedViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessConfirmedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessConfirmedViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessInvitedViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessInvitedViewModel.cs index fa432c5b70..5f9e450a0c 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessInvitedViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessInvitedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessInvitedViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryTimedOutViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryTimedOutViewModel.cs index dd3ae3dd82..6d166b3ebb 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryTimedOutViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryTimedOutViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessRecoveryTimedOutViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryViewModel.cs index 3811b49ff0..743a0707fc 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessRecoveryViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessRejectedViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessRejectedViewModel.cs index 101cb9c167..c704e121a3 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessRejectedViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessRejectedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessRejectedViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/FailedAuthAttemptModel.cs b/src/Core/Auth/Models/Mail/FailedAuthAttemptModel.cs new file mode 100644 index 0000000000..c67ac4a3d3 --- /dev/null +++ b/src/Core/Auth/Models/Mail/FailedAuthAttemptModel.cs @@ -0,0 +1,13 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; +using Bit.Core.Models.Mail; + +namespace Bit.Core.Auth.Models.Mail; + +public class FailedAuthAttemptModel : NewDeviceLoggedInModel +{ + public string AffectedEmail { get; set; } + public TwoFactorProviderType TwoFactorType { get; set; } +} diff --git a/src/Core/Auth/Models/Mail/FailedAuthAttemptsModel.cs b/src/Core/Auth/Models/Mail/FailedAuthAttemptsModel.cs deleted file mode 100644 index 2d5bc7eb15..0000000000 --- a/src/Core/Auth/Models/Mail/FailedAuthAttemptsModel.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.Models.Mail; - -namespace Bit.Core.Auth.Models.Mail; - -public class FailedAuthAttemptsModel : NewDeviceLoggedInModel -{ - public string AffectedEmail { get; set; } -} diff --git a/src/Core/Auth/Models/Mail/MasterPasswordHintViewModel.cs b/src/Core/Auth/Models/Mail/MasterPasswordHintViewModel.cs index e8ee28fc11..2c2f8343ea 100644 --- a/src/Core/Auth/Models/Mail/MasterPasswordHintViewModel.cs +++ b/src/Core/Auth/Models/Mail/MasterPasswordHintViewModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.Auth.Models.Mail; diff --git a/src/Core/Auth/Models/Mail/PasswordlessSignInModel.cs b/src/Core/Auth/Models/Mail/PasswordlessSignInModel.cs index 4b195b54a8..e8a07a7ec5 100644 --- a/src/Core/Auth/Models/Mail/PasswordlessSignInModel.cs +++ b/src/Core/Auth/Models/Mail/PasswordlessSignInModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Auth.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Auth.Models.Mail; public class PasswordlessSignInModel { diff --git a/src/Core/Auth/Models/Mail/RecoverTwoFactorModel.cs b/src/Core/Auth/Models/Mail/RecoverTwoFactorModel.cs index 82c2bc5303..ba11f3a442 100644 --- a/src/Core/Auth/Models/Mail/RecoverTwoFactorModel.cs +++ b/src/Core/Auth/Models/Mail/RecoverTwoFactorModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.Auth.Models.Mail; diff --git a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs index f1863da691..fe42093111 100644 --- a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs +++ b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.Auth.Models.Mail; diff --git a/src/Core/Auth/Models/Mail/VerifyDeleteModel.cs b/src/Core/Auth/Models/Mail/VerifyDeleteModel.cs index d6b7b3a445..44b88a69a8 100644 --- a/src/Core/Auth/Models/Mail/VerifyDeleteModel.cs +++ b/src/Core/Auth/Models/Mail/VerifyDeleteModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.Auth.Models.Mail; diff --git a/src/Core/Auth/Models/Mail/VerifyEmailModel.cs b/src/Core/Auth/Models/Mail/VerifyEmailModel.cs index 703de7e045..ea1d7f3398 100644 --- a/src/Core/Auth/Models/Mail/VerifyEmailModel.cs +++ b/src/Core/Auth/Models/Mail/VerifyEmailModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.Auth.Models.Mail; diff --git a/src/Core/Auth/Models/TwoFactorProvider.cs b/src/Core/Auth/Models/TwoFactorProvider.cs index 04ef4d7cb2..9152769425 100644 --- a/src/Core/Auth/Models/TwoFactorProvider.cs +++ b/src/Core/Auth/Models/TwoFactorProvider.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Auth.Enums; using Fido2NetLib.Objects; diff --git a/src/Core/Auth/Repositories/IAuthRequestRepository.cs b/src/Core/Auth/Repositories/IAuthRequestRepository.cs index 3b01a452f9..7a66ad6e34 100644 --- a/src/Core/Auth/Repositories/IAuthRequestRepository.cs +++ b/src/Core/Auth/Repositories/IAuthRequestRepository.cs @@ -9,6 +9,13 @@ public interface IAuthRequestRepository : IRepository { Task DeleteExpiredAsync(TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration); Task> GetManyByUserIdAsync(Guid userId); + /// + /// Gets all active pending auth requests for a user. Each auth request in the collection will be associated with a different + /// device. It will be the most current request for the device. + /// + /// UserId of the owner of the AuthRequests + /// a collection Auth request details or empty + Task> GetManyPendingAuthRequestByUserId(Guid userId); Task> GetManyPendingByOrganizationIdAsync(Guid organizationId); Task> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable ids); Task UpdateManyAsync(IEnumerable authRequests); diff --git a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs index 6a8fe9dd17..4331179554 100644 --- a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; diff --git a/src/Core/Auth/Services/IAuthRequestService.cs b/src/Core/Auth/Services/IAuthRequestService.cs index 4e057f0ccf..d81f6e7c8c 100644 --- a/src/Core/Auth/Services/IAuthRequestService.cs +++ b/src/Core/Auth/Services/IAuthRequestService.cs @@ -1,5 +1,9 @@ using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Exceptions; using Bit.Core.Auth.Models.Api.Request.AuthRequest; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Settings; #nullable enable @@ -7,8 +11,41 @@ namespace Bit.Core.Auth.Services; public interface IAuthRequestService { - Task GetAuthRequestAsync(Guid id, Guid userId); - Task GetValidatedAuthRequestAsync(Guid id, string code); + /// + /// Fetches an authRequest by Id. Returns AuthRequest if AuthRequest.UserId mateches + /// userId. Returns null if the user doesn't match or if the AuthRequest is not found. + /// + /// Authrequest Id being fetched + /// user who owns AuthRequest + /// An AuthRequest or null + Task GetAuthRequestAsync(Guid authRequestId, Guid userId); + /// + /// Fetches the authrequest from the database with the id provided. Then checks + /// the accessCode against the AuthRequest.AccessCode from the database. accessCodes + /// must match the found authRequest, and the AuthRequest must not be expired. Expiration + /// is configured in + /// + /// AuthRequest being acted on + /// Access code of the authrequest, must match saved database value + /// A valid AuthRequest or null + Task GetValidatedAuthRequestAsync(Guid authRequestId, string accessCode); + /// + /// Validates and Creates an in the database, as well as pushes it through notifications services + /// + /// + /// This method can only be called inside of an HTTP call because of it's reliance on + /// Task CreateAuthRequestAsync(AuthRequestCreateRequestModel model); + /// + /// Updates the AuthRequest per the AuthRequestUpdateRequestModel context. This approves + /// or rejects the login request. + /// + /// AuthRequest being acted on. + /// User acting on AuthRequest + /// Update context for the AuthRequest + /// retuns an AuthRequest or throws an exception + /// Thows if the AuthRequest has already been Approved/Rejected + /// Throws if the AuthRequest as expired or the userId doesn't match + /// Throws if the device isn't associated with the UserId Task UpdateAuthRequestAsync(Guid authRequestId, Guid userId, AuthRequestUpdateRequestModel model); } diff --git a/src/Core/Auth/Services/ITwoFactorEmailService.cs b/src/Core/Auth/Services/ITwoFactorEmailService.cs new file mode 100644 index 0000000000..b0d0de6b01 --- /dev/null +++ b/src/Core/Auth/Services/ITwoFactorEmailService.cs @@ -0,0 +1,11 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.Services; + +public interface ITwoFactorEmailService +{ + Task SendTwoFactorEmailAsync(User user); + Task SendTwoFactorSetupEmailAsync(User user); + Task SendNewDeviceVerificationEmailAsync(User user); + Task VerifyTwoFactorTokenAsync(User user, string token); +} diff --git a/src/Core/Auth/Services/Implementations/AuthRequestService.cs b/src/Core/Auth/Services/Implementations/AuthRequestService.cs index 0fd1846d00..11682b524f 100644 --- a/src/Core/Auth/Services/Implementations/AuthRequestService.cs +++ b/src/Core/Auth/Services/Implementations/AuthRequestService.cs @@ -58,9 +58,9 @@ public class AuthRequestService : IAuthRequestService _logger = logger; } - public async Task GetAuthRequestAsync(Guid id, Guid userId) + public async Task GetAuthRequestAsync(Guid authRequestId, Guid userId) { - var authRequest = await _authRequestRepository.GetByIdAsync(id); + var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId); if (authRequest == null || authRequest.UserId != userId) { return null; @@ -69,10 +69,10 @@ public class AuthRequestService : IAuthRequestService return authRequest; } - public async Task GetValidatedAuthRequestAsync(Guid id, string code) + public async Task GetValidatedAuthRequestAsync(Guid authRequestId, string accessCode) { - var authRequest = await _authRequestRepository.GetByIdAsync(id); - if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, code)) + var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId); + if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, accessCode)) { return null; } @@ -85,12 +85,6 @@ public class AuthRequestService : IAuthRequestService return authRequest; } - /// - /// Validates and Creates an in the database, as well as pushes it through notifications services - /// - /// - /// This method can only be called inside of an HTTP call because of it's reliance on - /// public async Task CreateAuthRequestAsync(AuthRequestCreateRequestModel model) { if (!_currentContext.DeviceType.HasValue) diff --git a/src/Core/Auth/Services/Implementations/SsoConfigService.cs b/src/Core/Auth/Services/Implementations/SsoConfigService.cs index bf7e2d56fe..fe8d9bdd6e 100644 --- a/src/Core/Auth/Services/Implementations/SsoConfigService.cs +++ b/src/Core/Auth/Services/Implementations/SsoConfigService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; diff --git a/src/Core/Auth/Services/Implementations/TwoFactorEmailService.cs b/src/Core/Auth/Services/Implementations/TwoFactorEmailService.cs new file mode 100644 index 0000000000..cb26e46cd5 --- /dev/null +++ b/src/Core/Auth/Services/Implementations/TwoFactorEmailService.cs @@ -0,0 +1,119 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Bit.Core.Auth.Enums; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Core.Auth.Enums; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.Services; + +public class TwoFactorEmailService : ITwoFactorEmailService +{ + private readonly ICurrentContext _currentContext; + private readonly UserManager _userManager; + private readonly IMailService _mailService; + + public TwoFactorEmailService( + ICurrentContext currentContext, + IMailService mailService, + UserManager userManager + ) + { + _currentContext = currentContext; + _userManager = userManager; + _mailService = mailService; + } + + /// + /// Sends a two-factor email to the user with an OTP token for login + /// + /// The user to whom the email should be sent + /// Thrown if the user does not have an email for email 2FA + public async Task SendTwoFactorEmailAsync(User user) + { + await VerifyAndSendTwoFactorEmailAsync(user, TwoFactorEmailPurpose.Login); + } + + /// + /// Sends a two-factor email to the user with an OTP for setting up 2FA + /// + /// The user to whom the email should be sent + /// Thrown if the user does not have an email for email 2FA + public async Task SendTwoFactorSetupEmailAsync(User user) + { + await VerifyAndSendTwoFactorEmailAsync(user, TwoFactorEmailPurpose.Setup); + } + + /// + /// Sends a new device verification email to the user with an OTP token + /// + /// The user to whom the email should be sent + /// Thrown if the user is not provided + public async Task SendNewDeviceVerificationEmailAsync(User user) + { + ArgumentNullException.ThrowIfNull(user); + + var token = await _userManager.GenerateUserTokenAsync(user, TokenOptions.DefaultEmailProvider, + "otp:" + user.Email); + + var deviceType = _currentContext.DeviceType?.GetType().GetMember(_currentContext.DeviceType?.ToString()) + .FirstOrDefault()?.GetCustomAttribute()?.GetName() ?? "Unknown Browser"; + + await _mailService.SendTwoFactorEmailAsync( + user.Email, user.Email, token, _currentContext.IpAddress, deviceType, TwoFactorEmailPurpose.NewDeviceVerification); + } + + /// + /// Verifies the two-factor token for the specified user + /// + /// The user for whom the token should be verified + /// The token to verify + /// Thrown if the user does not have an email for email 2FA + public async Task VerifyTwoFactorTokenAsync(User user, string token) + { + var email = GetUserTwoFactorEmail(user); + return await _userManager.VerifyTwoFactorTokenAsync(user, + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), token); + } + + /// + /// Sends a two-factor email with the specified purpose to the user only if they have 2FA email set up + /// + /// The user to whom the email should be sent + /// The purpose of the email + /// Thrown if the user does not have an email set up for 2FA + private async Task VerifyAndSendTwoFactorEmailAsync(User user, TwoFactorEmailPurpose purpose) + { + var email = GetUserTwoFactorEmail(user); + var token = await _userManager.GenerateTwoFactorTokenAsync(user, + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email)); + + var deviceType = _currentContext.DeviceType?.GetType().GetMember(_currentContext.DeviceType?.ToString()) + .FirstOrDefault()?.GetCustomAttribute()?.GetName() ?? "Unknown Browser"; + + await _mailService.SendTwoFactorEmailAsync( + email, user.Email, token, _currentContext.IpAddress, deviceType, purpose); + } + + /// + /// Verifies the user has email 2FA and will return the email if present and throw otherwise. + /// + /// The user to check + /// The user's 2FA email address + /// + private string GetUserTwoFactorEmail(User user) + { + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); + if (provider == null || provider.MetaData == null || !provider.MetaData.TryGetValue("Email", out var emailValue)) + { + throw new ArgumentNullException("No email."); + } + return ((string)emailValue).ToLowerInvariant(); + } +} diff --git a/src/Core/Auth/UserFeatures/PasswordValidation/PasswordValidationConstants.cs b/src/Core/Auth/UserFeatures/PasswordValidation/PasswordValidationConstants.cs new file mode 100644 index 0000000000..b0803cd3cd --- /dev/null +++ b/src/Core/Auth/UserFeatures/PasswordValidation/PasswordValidationConstants.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Auth.UserFeatures.PasswordValidation; + +public static class PasswordValidationConstants +{ + public const int PasswordHasherKdfIterations = 100000; +} diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 289bbff7f8..991be2b764 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; diff --git a/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs new file mode 100644 index 0000000000..f944de381e --- /dev/null +++ b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using Bit.Core.Auth.Identity; + +namespace Bit.Core.Auth.UserFeatures.SendAccess; + +public static class SendAccessClaimsPrincipalExtensions +{ + public static Guid GetSendId(this ClaimsPrincipal user) + { + ArgumentNullException.ThrowIfNull(user); + + var sendIdClaim = user.FindFirst(Claims.SendAccessClaims.SendId) + ?? throw new InvalidOperationException("send_id claim not found."); + + if (!Guid.TryParse(sendIdClaim.Value, out var sendGuid)) + { + throw new InvalidOperationException("Invalid send_id claim value."); + } + + return sendGuid; + } +} diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs index 8ef586ab51..719ff9ce9d 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs @@ -91,7 +91,7 @@ public class TdeOffboardingPasswordCommand : ITdeOffboardingPasswordCommand user.MasterPasswordHint = hint; await _userRepository.ReplaceAsync(user); - await _eventService.LogUserEventAsync(user.Id, EventType.User_UpdatedTempPassword); + await _eventService.LogUserEventAsync(user.Id, EventType.User_TdeOffboardingPasswordSet); await _pushService.PushLogOutAsync(user.Id); return IdentityResult.Success; diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs index 8d4bd49e42..cc86d3d71d 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Repositories; diff --git a/src/Core/Auth/UserFeatures/WebAuthnLogin/ICreateWebAuthnLoginCredentialCommand.cs b/src/Core/Auth/UserFeatures/WebAuthnLogin/ICreateWebAuthnLoginCredentialCommand.cs index c25e226a32..61a573cb2d 100644 --- a/src/Core/Auth/UserFeatures/WebAuthnLogin/ICreateWebAuthnLoginCredentialCommand.cs +++ b/src/Core/Auth/UserFeatures/WebAuthnLogin/ICreateWebAuthnLoginCredentialCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Fido2NetLib; namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin; diff --git a/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs b/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs index 65c98dea3b..795fa95b9d 100644 --- a/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs +++ b/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; using Bit.Core.Utilities; diff --git a/src/Core/Billing/BillingException.cs b/src/Core/Billing/BillingException.cs index c2b1b9f457..1203a15f7b 100644 --- a/src/Core/Billing/BillingException.cs +++ b/src/Core/Billing/BillingException.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Billing; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Billing; public class BillingException( string response = null, diff --git a/src/Core/Billing/Caches/ISetupIntentCache.cs b/src/Core/Billing/Caches/ISetupIntentCache.cs index 0990266239..8e53e8fb09 100644 --- a/src/Core/Billing/Caches/ISetupIntentCache.cs +++ b/src/Core/Billing/Caches/ISetupIntentCache.cs @@ -2,9 +2,8 @@ public interface ISetupIntentCache { - Task Get(Guid subscriberId); - - Task Remove(Guid subscriberId); - + Task GetSetupIntentIdForSubscriber(Guid subscriberId); + Task GetSubscriberIdForSetupIntent(string setupIntentId); + Task RemoveSetupIntentForSubscriber(Guid subscriberId); Task Set(Guid subscriberId, string setupIntentId); } diff --git a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs index ceb512a0e3..8833c928fe 100644 --- a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs +++ b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs @@ -7,26 +7,41 @@ public class SetupIntentDistributedCache( [FromKeyedServices("persistent")] IDistributedCache distributedCache) : ISetupIntentCache { - public async Task Get(Guid subscriberId) + public async Task GetSetupIntentIdForSubscriber(Guid subscriberId) { - var cacheKey = GetCacheKey(subscriberId); - + var cacheKey = GetCacheKeyBySubscriberId(subscriberId); return await distributedCache.GetStringAsync(cacheKey); } - public async Task Remove(Guid subscriberId) + public async Task GetSubscriberIdForSetupIntent(string setupIntentId) { - var cacheKey = GetCacheKey(subscriberId); + var cacheKey = GetCacheKeyBySetupIntentId(setupIntentId); + var value = await distributedCache.GetStringAsync(cacheKey); + if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var subscriberId)) + { + return null; + } + return subscriberId; + } + public async Task RemoveSetupIntentForSubscriber(Guid subscriberId) + { + var cacheKey = GetCacheKeyBySubscriberId(subscriberId); await distributedCache.RemoveAsync(cacheKey); } public async Task Set(Guid subscriberId, string setupIntentId) { - var cacheKey = GetCacheKey(subscriberId); - - await distributedCache.SetStringAsync(cacheKey, setupIntentId); + var bySubscriberIdCacheKey = GetCacheKeyBySubscriberId(subscriberId); + var bySetupIntentIdCacheKey = GetCacheKeyBySetupIntentId(setupIntentId); + await Task.WhenAll( + distributedCache.SetStringAsync(bySubscriberIdCacheKey, setupIntentId), + distributedCache.SetStringAsync(bySetupIntentIdCacheKey, subscriberId.ToString())); } - private static string GetCacheKey(Guid subscriberId) => $"pending_bank_account_{subscriberId}"; + private static string GetCacheKeyBySetupIntentId(string setupIntentId) => + $"subscriber_id_for_setup_intent_id_{setupIntentId}"; + + private static string GetCacheKeyBySubscriberId(Guid subscriberId) => + $"setup_intent_id_for_subscriber_id_{subscriberId}"; } diff --git a/src/Core/Billing/Commands/BaseBillingCommand.cs b/src/Core/Billing/Commands/BaseBillingCommand.cs new file mode 100644 index 0000000000..b3e938548d --- /dev/null +++ b/src/Core/Billing/Commands/BaseBillingCommand.cs @@ -0,0 +1,81 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Commands; + +using static StripeConstants; + +public abstract class BaseBillingCommand( + ILogger logger) +{ + protected string CommandName => GetType().Name; + + /// + /// Override this property to set a client-facing conflict response in the case a is thrown + /// during the command's execution. + /// + protected virtual Conflict? DefaultConflict => null; + + /// + /// Executes the provided function within a predefined execution context, handling any exceptions that occur during the process. + /// + /// The type of the successful result expected from the provided function. + /// A function that performs an operation and returns a . + /// A task that represents the operation. The result provides a which may indicate success or an error outcome. + protected async Task> HandleAsync( + Func>> function) + { + try + { + return await function(); + } + catch (StripeException stripeException) when (ErrorCodes.Get().Contains(stripeException.StripeError.Code)) + { + return stripeException.StripeError.Code switch + { + ErrorCodes.CustomerTaxLocationInvalid => + new BadRequest( + "Your location wasn't recognized. Please ensure your country and postal code are valid and try again."), + + ErrorCodes.PaymentMethodMicroDepositVerificationAttemptsExceeded => + new BadRequest( + "You have exceeded the number of allowed verification attempts. Please contact support for assistance."), + + ErrorCodes.PaymentMethodMicroDepositVerificationDescriptorCodeMismatch => + new BadRequest( + "The verification code you provided does not match the one sent to your bank account. Please try again."), + + ErrorCodes.PaymentMethodMicroDepositVerificationTimeout => + new BadRequest( + "Your bank account was not verified within the required time period. Please contact support for assistance."), + + ErrorCodes.TaxIdInvalid => + new BadRequest( + "The tax ID number you provided was invalid. Please try again or contact support for assistance."), + + _ => new Unhandled(stripeException) + }; + } + catch (ConflictException conflictException) + { + logger.LogError("{Command}: {Message}", CommandName, conflictException.Message); + return DefaultConflict != null ? + DefaultConflict : + new Unhandled(conflictException); + } + catch (StripeException stripeException) + { + logger.LogError(stripeException, + "{Command}: An error occurred while communicating with Stripe | Code = {Code}", CommandName, + stripeException.StripeError.Code); + return new Unhandled(stripeException); + } + catch (Exception exception) + { + logger.LogError(exception, "{Command}: An unknown error occurred during execution", CommandName); + return new Unhandled(exception); + } + } +} diff --git a/src/Core/Billing/Commands/BillingCommandResult.cs b/src/Core/Billing/Commands/BillingCommandResult.cs new file mode 100644 index 0000000000..db260e7038 --- /dev/null +++ b/src/Core/Billing/Commands/BillingCommandResult.cs @@ -0,0 +1,56 @@ +using OneOf; + +namespace Bit.Core.Billing.Commands; + +public record BadRequest(string Response); +public record Conflict(string Response); +public record Unhandled(Exception? Exception = null, string Response = "Something went wrong with your request. Please contact support for assistance."); + +/// +/// A union type representing the result of a billing command. +/// +/// Choices include: +/// +/// : Success +/// : Invalid input +/// : A known, but unresolvable issue +/// : An unknown issue +/// +/// +/// +/// The successful result type of the operation. +public class BillingCommandResult(OneOf input) + : OneOfBase(input) +{ + public static implicit operator BillingCommandResult(T output) => new(output); + public static implicit operator BillingCommandResult(BadRequest badRequest) => new(badRequest); + public static implicit operator BillingCommandResult(Conflict conflict) => new(conflict); + public static implicit operator BillingCommandResult(Unhandled unhandled) => new(unhandled); + + public BillingCommandResult Map(Func f) + => Match( + value => new BillingCommandResult(f(value)), + badRequest => new BillingCommandResult(badRequest), + conflict => new BillingCommandResult(conflict), + unhandled => new BillingCommandResult(unhandled)); + + public Task TapAsync(Func f) => Match( + f, + _ => Task.CompletedTask, + _ => Task.CompletedTask, + _ => Task.CompletedTask); +} + +public static class BillingCommandResultExtensions +{ + public static async Task> AndThenAsync( + this Task> task, Func>> binder) + { + var result = await task; + return await result.Match( + binder, + badRequest => Task.FromResult(new BillingCommandResult(badRequest)), + conflict => Task.FromResult(new BillingCommandResult(conflict)), + unhandled => Task.FromResult(new BillingCommandResult(unhandled))); + } +} diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 0cffad72d3..131adfedf8 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Billing.Constants; +using System.Reflection; + +namespace Bit.Core.Billing.Constants; public static class StripeConstants { @@ -36,6 +38,13 @@ public static class StripeConstants public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch"; public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout"; public const string TaxIdInvalid = "tax_id_invalid"; + + public static string[] Get() => + typeof(ErrorCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(fi => fi is { IsLiteral: true, IsInitOnly: false } && fi.FieldType == typeof(string)) + .Select(fi => (string)fi.GetValue(null)!) + .ToArray(); } public static class InvoiceStatus @@ -51,6 +60,8 @@ public static class StripeConstants public const string InvoiceApproved = "invoice_approved"; public const string OrganizationId = "organizationId"; public const string ProviderId = "providerId"; + public const string Region = "region"; + public const string RetiredBraintreeCustomerId = "btCustomerId_old"; public const string UserId = "userId"; } @@ -68,6 +79,7 @@ public static class StripeConstants public static class Prices { public const string StoragePlanPersonal = "personal-storage-gb-annually"; + public const string PremiumAnnually = "premium-annually"; } public static class ProrationBehavior @@ -102,9 +114,31 @@ public static class StripeConstants public const string SpanishNIF = "es_cif"; } + public static class TaxIdVerificationStatus + { + public const string Pending = "pending"; + public const string Unavailable = "unavailable"; + public const string Unverified = "unverified"; + public const string Verified = "verified"; + } + + public static class TaxRegistrationStatus + { + public const string Active = "active"; + public const string Expired = "expired"; + public const string Scheduled = "scheduled"; + } + public static class ValidateTaxLocationTiming { public const string Deferred = "deferred"; public const string Immediately = "immediately"; } + + public static class MissingPaymentMethodBehaviorOptions + { + public const string CreateInvoice = "create_invoice"; + public const string Cancel = "cancel"; + public const string Pause = "pause"; + } } diff --git a/src/Core/Billing/Enums/PlanCadenceType.cs b/src/Core/Billing/Enums/PlanCadenceType.cs new file mode 100644 index 0000000000..9e6fa69832 --- /dev/null +++ b/src/Core/Billing/Enums/PlanCadenceType.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Enums; + +public enum PlanCadenceType +{ + Annually, + Monthly +} diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index c8a1496726..7f81bfd33f 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -22,6 +22,19 @@ public static class BillingExtensions _ => throw new BillingException($"PlanType {planType} could not be matched to a ProductTierType") }; + public static bool IsBusinessProductTierType(this PlanType planType) + => IsBusinessProductTierType(planType.GetProductTier()); + + public static bool IsBusinessProductTierType(this ProductTierType productTierType) + => productTierType switch + { + ProductTierType.Free => false, + ProductTierType.Families => false, + ProductTierType.Enterprise => true, + ProductTierType.Teams => true, + ProductTierType.TeamsStarter => true + }; + public static bool IsBillable(this Provider provider) => provider is { @@ -36,6 +49,10 @@ public static class BillingExtensions Status: ProviderStatusType.Billable }; + // Reseller types do not have Stripe entities + public static bool IsStripeSupported(this ProviderType providerType) => + providerType is ProviderType.Msp or ProviderType.BusinessUnit; + public static bool SupportsConsolidatedBilling(this ProviderType providerType) => providerType is ProviderType.Msp or ProviderType.BusinessUnit; diff --git a/src/Core/Billing/Extensions/InvoiceExtensions.cs b/src/Core/Billing/Extensions/InvoiceExtensions.cs new file mode 100644 index 0000000000..bb9f7588bf --- /dev/null +++ b/src/Core/Billing/Extensions/InvoiceExtensions.cs @@ -0,0 +1,76 @@ +using System.Text.RegularExpressions; +using Stripe; + +namespace Bit.Core.Billing.Extensions; + +public static class InvoiceExtensions +{ + /// + /// Formats invoice line items specifically for provider invoices, standardizing product descriptions + /// and ensuring consistent tax representation. + /// + /// The Stripe invoice containing line items + /// The associated subscription (for future extensibility) + /// A list of formatted invoice item descriptions + public static List FormatForProvider(this Invoice invoice, Subscription subscription) + { + var items = new List(); + + // Return empty list if no line items + if (invoice.Lines == null) + { + return items; + } + + foreach (var line in invoice.Lines.Data ?? new List()) + { + // Skip null lines or lines without description + if (line?.Description == null) + { + continue; + } + + var description = line.Description; + + // Handle Provider Portal and Business Unit Portal service lines + if (description.Contains("Provider Portal") || description.Contains("Business Unit")) + { + var priceMatch = Regex.Match(description, @"\(at \$[\d,]+\.?\d* / month\)"); + var priceInfo = priceMatch.Success ? priceMatch.Value : ""; + + var standardizedDescription = $"{line.Quantity} × Manage service provider {priceInfo}"; + items.Add(standardizedDescription); + } + // Handle tax lines + else if (description.ToLower().Contains("tax")) + { + var priceMatch = Regex.Match(description, @"\(at \$[\d,]+\.?\d* / month\)"); + var priceInfo = priceMatch.Success ? priceMatch.Value : ""; + + // If no price info found in description, calculate from amount + if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0) + { + var pricePerItem = (line.Amount / 100m) / line.Quantity; + priceInfo = $"(at ${pricePerItem:F2} / month)"; + } + + var taxDescription = $"{line.Quantity} × Tax {priceInfo}"; + items.Add(taxDescription); + } + // Handle other line items as-is + else + { + items.Add(description); + } + } + + // Add fallback tax from invoice-level tax if present and not already included + if (invoice.Tax.HasValue && invoice.Tax.Value > 0) + { + var taxAmount = invoice.Tax.Value / 100m; + items.Add($"1 × Tax (at ${taxAmount:F2} / month)"); + } + + return items; + } +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 5c7a42e9b8..7aec422a4b 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -1,10 +1,15 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches.Implementations; using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Billing.Organizations.Services; +using Bit.Core.Billing.Payment; +using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; -using Bit.Core.Billing.Tax.Commands; +using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Billing.Tax.Services; using Bit.Core.Billing.Tax.Services.Implementations; @@ -21,11 +26,27 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddKeyedTransient(AutomaticTaxFactory.PersonalUse); - services.AddKeyedTransient(AutomaticTaxFactory.BusinessUse); - services.AddTransient(); services.AddLicenseServices(); services.AddPricingClient(); - services.AddTransient(); + services.AddPaymentOperations(); + services.AddOrganizationLicenseCommandsQueries(); + services.AddPremiumCommands(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + } + + private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + + private static void AddPremiumCommands(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddTransient(); } } diff --git a/src/Core/Billing/Extensions/SubscriberExtensions.cs b/src/Core/Billing/Extensions/SubscriberExtensions.cs index e322ed7317..fc804de224 100644 --- a/src/Core/Billing/Extensions/SubscriberExtensions.cs +++ b/src/Core/Billing/Extensions/SubscriberExtensions.cs @@ -1,4 +1,8 @@ -using Bit.Core.Entities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Entities; namespace Bit.Core.Billing.Extensions; @@ -23,4 +27,14 @@ public static class SubscriberExtensions ? subscriberName : subscriberName[..30]; } + + public static ProductUsageType GetProductUsageType(this ISubscriber subscriber) + => subscriber switch + { + User => ProductUsageType.Personal, + Organization organization when organization.PlanType.GetProductTier() is ProductTierType.Free or ProductTierType.Families => ProductUsageType.Personal, + Organization => ProductUsageType.Business, + Provider => ProductUsageType.Business, + _ => throw new ArgumentOutOfRangeException(nameof(subscriber)) + }; } diff --git a/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs b/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs index 184d8dad23..8cd3438191 100644 --- a/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs +++ b/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs @@ -1,81 +1,116 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Enums; using Bit.Core.Models.Business; namespace Bit.Core.Billing.Licenses.Extensions; public static class LicenseExtensions { - public static DateTime CalculateFreshExpirationDate(this Organization org, SubscriptionInfo subscriptionInfo) + public static DateTime CalculateFreshExpirationDate(this Organization org, SubscriptionInfo subscriptionInfo, DateTime issued) { + if (subscriptionInfo?.Subscription == null) { - if (org.PlanType == PlanType.Custom && org.ExpirationDate.HasValue) - { - return org.ExpirationDate.Value; - } - - return DateTime.UtcNow.AddDays(7); + // Subscription isn't setup yet, so fallback to the organization's expiration date + // If there isn't an expiration date on the org, treat it as a free trial + return org.ExpirationDate ?? issued.AddDays(7); } var subscription = subscriptionInfo.Subscription; if (subscription.TrialEndDate > DateTime.UtcNow) { + // Still trialing, use trial's end date return subscription.TrialEndDate.Value; } - if (org.ExpirationDate.HasValue && org.ExpirationDate.Value < DateTime.UtcNow) + if (org.ExpirationDate < DateTime.UtcNow) { + // Organization is expired return org.ExpirationDate.Value; } - if (subscription.PeriodEndDate.HasValue && subscription.PeriodDuration > TimeSpan.FromDays(180)) + if (subscription.PeriodDuration > TimeSpan.FromDays(180)) { + // Annual subscription - include grace period to give the administrators time to upload a new license return subscription.PeriodEndDate - .Value - .AddDays(Bit.Core.Constants.OrganizationSelfHostSubscriptionGracePeriodDays); + !.Value + .AddDays(Core.Constants.OrganizationSelfHostSubscriptionGracePeriodDays); } - return org.ExpirationDate?.AddMonths(11) ?? DateTime.UtcNow.AddYears(1); + // Monthly subscription - giving an annual expiration to not burnden admins to upload fresh licenses each month + return org.ExpirationDate?.AddMonths(11) ?? issued.AddYears(1); } - public static DateTime CalculateFreshRefreshDate(this Organization org, SubscriptionInfo subscriptionInfo, DateTime expirationDate) + public static DateTime CalculateFreshRefreshDate(this Organization org, SubscriptionInfo subscriptionInfo, DateTime issued) { - if (subscriptionInfo?.Subscription == null || - subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow || - org.ExpirationDate < DateTime.UtcNow) - { - return expirationDate; - } - return subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180) || - DateTime.UtcNow - expirationDate > TimeSpan.FromDays(30) - ? DateTime.UtcNow.AddDays(30) - : expirationDate; - } - - public static DateTime CalculateFreshExpirationDateWithoutGracePeriod(this Organization org, SubscriptionInfo subscriptionInfo, DateTime expirationDate) - { - if (subscriptionInfo?.Subscription is null) + if (subscriptionInfo?.Subscription == null) { - return expirationDate; + // Subscription isn't setup yet, so fallback to the organization's expiration date + // If there isn't an expiration date on the org, treat it as a free trial + return org.ExpirationDate ?? issued.AddDays(7); } var subscription = subscriptionInfo.Subscription; - if (subscription.TrialEndDate <= DateTime.UtcNow && - org.ExpirationDate >= DateTime.UtcNow && - subscription.PeriodEndDate.HasValue && - subscription.PeriodDuration > TimeSpan.FromDays(180)) + if (subscription.TrialEndDate > DateTime.UtcNow) { - return subscription.PeriodEndDate.Value; + // Still trialing, use trial's end date + return subscription.TrialEndDate.Value; } - return expirationDate; + if (org.ExpirationDate < DateTime.UtcNow) + { + // Organization is expired + return org.ExpirationDate.Value; + } + + if (subscription.PeriodDuration > TimeSpan.FromDays(180)) + { + // Annual subscription - refresh every 30 days to check for plan changes, cancellations, and payment issues + return issued.AddDays(30); + } + + var expires = org.ExpirationDate?.AddMonths(11) ?? issued.AddYears(1); + + // If expiration is more than 30 days in the past, refresh in 30 days instead of using the stale date to give + // them a chance to refresh. Otherwise, uses the expiration date + return issued - expires > TimeSpan.FromDays(30) + ? issued.AddDays(30) + : expires; } + public static DateTime? CalculateFreshExpirationDateWithoutGracePeriod(this Organization org, SubscriptionInfo subscriptionInfo) + { + // It doesn't make sense that this returns null sometimes. If the expiration date doesn't include a grace period + // then we should just return the expiration date instead of null. This is currently forcing the single consumer + // to check for nulls. + + // At some point in the future, we should update this. We can't easily, though, without breaking the signatures + // since `ExpirationWithoutGracePeriod` is included on them. So for now, I'll shake my fist and then move on. + + // Only set expiration without grace period for active, non-trial, annual subscriptions + if (subscriptionInfo?.Subscription != null && + subscriptionInfo.Subscription.TrialEndDate <= DateTime.UtcNow && + org.ExpirationDate >= DateTime.UtcNow && + subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180)) + { + return subscriptionInfo.Subscription.PeriodEndDate; + } + + // Otherwise, return null. + return null; + } + + public static bool CalculateIsTrialing(this Organization org, SubscriptionInfo subscriptionInfo) => + subscriptionInfo?.Subscription is null + ? !org.ExpirationDate.HasValue + : subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow; + public static T GetValue(this ClaimsPrincipal principal, string claimType) { var claim = principal.FindFirst(claimType); diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index b3f2ab4ec9..1e049d7f03 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -1,11 +1,12 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using System.Security.Claims; using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Enums; using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Licenses.Models; using Bit.Core.Enums; -using Bit.Core.Models.Business; namespace Bit.Core.Billing.Licenses.Services.Implementations; @@ -13,11 +14,12 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory> GenerateClaims(Organization entity, LicenseContext licenseContext) { + var issued = DateTime.UtcNow; var subscriptionInfo = licenseContext.SubscriptionInfo; - var expires = entity.CalculateFreshExpirationDate(subscriptionInfo); - var refresh = entity.CalculateFreshRefreshDate(subscriptionInfo, expires); - var expirationWithoutGracePeriod = entity.CalculateFreshExpirationDateWithoutGracePeriod(subscriptionInfo, expires); - var trial = IsTrialing(entity, subscriptionInfo); + var expires = entity.CalculateFreshExpirationDate(subscriptionInfo, issued); + var refresh = entity.CalculateFreshRefreshDate(subscriptionInfo, issued); + var expirationWithoutGracePeriod = entity.CalculateFreshExpirationDateWithoutGracePeriod(subscriptionInfo); + var trial = entity.CalculateIsTrialing(subscriptionInfo); var claims = new List { @@ -48,10 +50,9 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory - subscriptionInfo?.Subscription is null - ? org.PlanType != PlanType.Custom || !org.ExpirationDate.HasValue - : subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow; } diff --git a/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs index 2aaa5efdc1..5ad1a4a294 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs @@ -1,4 +1,7 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using System.Security.Claims; using Bit.Core.Billing.Licenses.Models; using Bit.Core.Entities; diff --git a/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs index b31da9efbc..3392594f06 100644 --- a/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs +++ b/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models.Api.Request.Accounts; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Billing.Enums; namespace Bit.Core.Billing.Models.Api.Requests.Accounts; diff --git a/src/Core/Billing/Models/BillingCommandResult.cs b/src/Core/Billing/Models/BillingCommandResult.cs deleted file mode 100644 index 1b8eefe8df..0000000000 --- a/src/Core/Billing/Models/BillingCommandResult.cs +++ /dev/null @@ -1,36 +0,0 @@ -using OneOf; - -namespace Bit.Core.Billing.Models; - -public record BadRequest(string TranslationKey) -{ - public static BadRequest TaxIdNumberInvalid => new(BillingErrorTranslationKeys.TaxIdInvalid); - public static BadRequest TaxLocationInvalid => new(BillingErrorTranslationKeys.CustomerTaxLocationInvalid); - public static BadRequest UnknownTaxIdType => new(BillingErrorTranslationKeys.UnknownTaxIdType); -} - -public record Unhandled(string TranslationKey = BillingErrorTranslationKeys.UnhandledError); - -public class BillingCommandResult : OneOfBase -{ - private BillingCommandResult(OneOf input) : base(input) { } - - public static implicit operator BillingCommandResult(T output) => new(output); - public static implicit operator BillingCommandResult(BadRequest badRequest) => new(badRequest); - public static implicit operator BillingCommandResult(Unhandled unhandled) => new(unhandled); -} - -public static class BillingErrorTranslationKeys -{ - // "The tax ID number you provided was invalid. Please try again or contact support." - public const string TaxIdInvalid = "taxIdInvalid"; - - // "Your location wasn't recognized. Please ensure your country and postal code are valid and try again." - public const string CustomerTaxLocationInvalid = "customerTaxLocationInvalid"; - - // "Something went wrong with your request. Please contact support." - public const string UnhandledError = "unhandledBillingError"; - - // "We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support." - public const string UnknownTaxIdType = "unknownTaxIdType"; -} diff --git a/src/Core/Billing/Models/BillingHistoryInfo.cs b/src/Core/Billing/Models/BillingHistoryInfo.cs index 03017b9b4d..3114e22fdf 100644 --- a/src/Core/Billing/Models/BillingHistoryInfo.cs +++ b/src/Core/Billing/Models/BillingHistoryInfo.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Enums; using Stripe; diff --git a/src/Core/Billing/Models/BillingInfo.cs b/src/Core/Billing/Models/BillingInfo.cs index 9bdc042570..5b7f2484be 100644 --- a/src/Core/Billing/Models/BillingInfo.cs +++ b/src/Core/Billing/Models/BillingInfo.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Stripe; namespace Bit.Core.Billing.Models; diff --git a/src/Core/Models/Business/ILicense.cs b/src/Core/Billing/Models/Business/ILicense.cs similarity index 93% rename from src/Core/Models/Business/ILicense.cs rename to src/Core/Billing/Models/Business/ILicense.cs index b0e295bdd9..2727541847 100644 --- a/src/Core/Models/Business/ILicense.cs +++ b/src/Core/Billing/Models/Business/ILicense.cs @@ -1,6 +1,6 @@ using System.Security.Cryptography.X509Certificates; -namespace Bit.Core.Models.Business; +namespace Bit.Core.Billing.Models.Business; public interface ILicense { diff --git a/src/Core/Models/Business/UserLicense.cs b/src/Core/Billing/Models/Business/UserLicense.cs similarity index 94% rename from src/Core/Models/Business/UserLicense.cs rename to src/Core/Billing/Models/Business/UserLicense.cs index 797aa6692a..d13de17d47 100644 --- a/src/Core/Models/Business/UserLicense.cs +++ b/src/Core/Billing/Models/Business/UserLicense.cs @@ -1,15 +1,19 @@ -using System.Reflection; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Reflection; using System.Security.Claims; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json.Serialization; using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Services; +using Bit.Core.Models.Business; -namespace Bit.Core.Models.Business; +namespace Bit.Core.Billing.Models.Business; public class UserLicense : ILicense { @@ -19,7 +23,7 @@ public class UserLicense : ILicense public UserLicense(User user, SubscriptionInfo subscriptionInfo, ILicensingService licenseService, int? version = null) { - LicenseType = Enums.LicenseType.User; + LicenseType = Core.Enums.LicenseType.User; LicenseKey = user.LicenseKey; Id = user.Id; Name = user.Name; @@ -41,7 +45,7 @@ public class UserLicense : ILicense public UserLicense(User user, ILicensingService licenseService, int? version = null) { - LicenseType = Enums.LicenseType.User; + LicenseType = Core.Enums.LicenseType.User; LicenseKey = user.LicenseKey; Id = user.Id; Name = user.Name; @@ -97,7 +101,7 @@ public class UserLicense : ILicense ) )) .OrderBy(p => p.Name) - .Select(p => $"{p.Name}:{Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") + .Select(p => $"{p.Name}:{Core.Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") .Aggregate((c, n) => $"{c}|{n}"); data = $"license:user|{props}"; } diff --git a/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs b/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs index b97390dcc9..019edccd04 100644 --- a/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs +++ b/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models.Mail; using Bit.Core.Billing.Enums; using Bit.Core.Enums; diff --git a/src/Core/Billing/Models/OffboardingSurveyResponse.cs b/src/Core/Billing/Models/OffboardingSurveyResponse.cs index cd966f40cc..0d55dcdc56 100644 --- a/src/Core/Billing/Models/OffboardingSurveyResponse.cs +++ b/src/Core/Billing/Models/OffboardingSurveyResponse.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Billing.Models; public class OffboardingSurveyResponse { diff --git a/src/Core/Billing/Models/PaymentMethod.cs b/src/Core/Billing/Models/PaymentMethod.cs index 2b8c59fa05..10eab97a8f 100644 --- a/src/Core/Billing/Models/PaymentMethod.cs +++ b/src/Core/Billing/Models/PaymentMethod.cs @@ -1,9 +1,12 @@ -using Bit.Core.Billing.Tax.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Tax.Models; namespace Bit.Core.Billing.Models; public record PaymentMethod( - long AccountCredit, + decimal AccountCredit, PaymentSource PaymentSource, string SubscriptionStatus, TaxInformation TaxInformation) diff --git a/src/Core/Billing/Models/PaymentSource.cs b/src/Core/Billing/Models/PaymentSource.cs index 44bbddc66b..130b0f71c4 100644 --- a/src/Core/Billing/Models/PaymentSource.cs +++ b/src/Core/Billing/Models/PaymentSource.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Extensions; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Extensions; using Bit.Core.Enums; namespace Bit.Core.Billing.Models; diff --git a/src/Core/Billing/Models/StaticStore/Plan.cs b/src/Core/Billing/Models/StaticStore/Plan.cs index d710594f46..540ea76582 100644 --- a/src/Core/Billing/Models/StaticStore/Plan.cs +++ b/src/Core/Billing/Models/StaticStore/Plan.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Enums; namespace Bit.Core.Models.StaticStore; diff --git a/src/Core/Billing/Models/StaticStore/SponsoredPlan.cs b/src/Core/Billing/Models/StaticStore/SponsoredPlan.cs index d0d98159a8..840a652225 100644 --- a/src/Core/Billing/Models/StaticStore/SponsoredPlan.cs +++ b/src/Core/Billing/Models/StaticStore/SponsoredPlan.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Enums; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations.OrganizationUsers; diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs new file mode 100644 index 0000000000..041e9bdbad --- /dev/null +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -0,0 +1,383 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Enums; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Organizations.Commands; + +using static Core.Constants; +using static StripeConstants; + +public interface IPreviewOrganizationTaxCommand +{ + Task> Run( + OrganizationSubscriptionPurchase purchase, + BillingAddress billingAddress); + + Task> Run( + Organization organization, + OrganizationSubscriptionPlanChange planChange, + BillingAddress billingAddress); + + Task> Run( + Organization organization, + OrganizationSubscriptionUpdate update); +} + +public class PreviewOrganizationTaxCommand( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) + : BaseBillingCommand(logger), IPreviewOrganizationTaxCommand +{ + public Task> Run( + OrganizationSubscriptionPurchase purchase, + BillingAddress billingAddress) + => HandleAsync<(decimal, decimal)>(async () => + { + var plan = await pricingClient.GetPlanOrThrow(purchase.PlanType); + + var options = GetBaseOptions(billingAddress, purchase.Tier != ProductTierType.Families); + + var items = new List(); + + switch (purchase) + { + case { PasswordManager.Sponsored: true }: + var sponsoredPlan = StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise); + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = sponsoredPlan.StripePlanId, + Quantity = 1 + }); + break; + + case { SecretsManager.Standalone: true }: + items.AddRange([ + new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.PasswordManager.StripeSeatPlanId, + Quantity = purchase.PasswordManager.Seats + }, + new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeSeatPlanId, + Quantity = purchase.SecretsManager.Seats + } + ]); + options.Coupon = CouponIDs.SecretsManagerStandalone; + break; + + default: + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.HasNonSeatBasedPasswordManagerPlan() + ? plan.PasswordManager.StripePlanId + : plan.PasswordManager.StripeSeatPlanId, + Quantity = purchase.PasswordManager.Seats + }); + + if (purchase.PasswordManager.AdditionalStorage > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.PasswordManager.StripeStoragePlanId, + Quantity = purchase.PasswordManager.AdditionalStorage + }); + } + + if (purchase.SecretsManager is { Seats: > 0 }) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeSeatPlanId, + Quantity = purchase.SecretsManager.Seats + }); + + if (purchase.SecretsManager.AdditionalServiceAccounts > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeServiceAccountPlanId, + Quantity = purchase.SecretsManager.AdditionalServiceAccounts + }); + } + } + + break; + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + }); + + public Task> Run( + Organization organization, + OrganizationSubscriptionPlanChange planChange, + BillingAddress billingAddress) + => HandleAsync<(decimal, decimal)>(async () => + { + if (organization.PlanType.GetProductTier() == ProductTierType.Free) + { + var options = GetBaseOptions(billingAddress, planChange.Tier != ProductTierType.Families); + + var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType); + + var items = new List + { + new () + { + Price = newPlan.HasNonSeatBasedPasswordManagerPlan() + ? newPlan.PasswordManager.StripePlanId + : newPlan.PasswordManager.StripeSeatPlanId, + Quantity = 2 + } + }; + + if (organization.UseSecretsManager && planChange.Tier != ProductTierType.Families) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeSeatPlanId, + Quantity = 2 + }); + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + } + else + { + if (organization is not + { + GatewayCustomerId: not null, + GatewaySubscriptionId: not null + }) + { + return new BadRequest("Organization does not have a subscription."); + } + + var options = GetBaseOptions(billingAddress, planChange.Tier != ProductTierType.Families); + + var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, + new SubscriptionGetOptions { Expand = ["customer"] }); + + if (subscription.Customer.Discount != null) + { + options.Coupon = subscription.Customer.Discount.Coupon.Id; + } + + var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType); + + var subscriptionItemsByPriceId = + subscription.Items.ToDictionary(subscriptionItem => subscriptionItem.Price.Id); + + var items = new List(); + + var passwordManagerSeats = subscriptionItemsByPriceId[ + currentPlan.HasNonSeatBasedPasswordManagerPlan() + ? currentPlan.PasswordManager.StripePlanId + : currentPlan.PasswordManager.StripeSeatPlanId]; + + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.HasNonSeatBasedPasswordManagerPlan() + ? newPlan.PasswordManager.StripePlanId + : newPlan.PasswordManager.StripeSeatPlanId, + Quantity = passwordManagerSeats.Quantity + }); + + var hasStorage = + subscriptionItemsByPriceId.TryGetValue(newPlan.PasswordManager.StripeStoragePlanId, + out var storage); + + if (hasStorage && storage != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.PasswordManager.StripeStoragePlanId, + Quantity = storage.Quantity + }); + } + + var hasSecretsManagerSeats = subscriptionItemsByPriceId.TryGetValue( + newPlan.SecretsManager.StripeSeatPlanId, + out var secretsManagerSeats); + + if (hasSecretsManagerSeats && secretsManagerSeats != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeSeatPlanId, + Quantity = secretsManagerSeats.Quantity + }); + + var hasServiceAccounts = + subscriptionItemsByPriceId.TryGetValue(newPlan.SecretsManager.StripeServiceAccountPlanId, + out var serviceAccounts); + + if (hasServiceAccounts && serviceAccounts != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeServiceAccountPlanId, + Quantity = serviceAccounts.Quantity + }); + } + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + } + }); + + public Task> Run( + Organization organization, + OrganizationSubscriptionUpdate update) + => HandleAsync<(decimal, decimal)>(async () => + { + if (organization is not + { + GatewayCustomerId: not null, + GatewaySubscriptionId: not null + }) + { + return new BadRequest("Organization does not have a subscription."); + } + + var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, + new SubscriptionGetOptions { Expand = ["customer.tax_ids"] }); + + var options = GetBaseOptions(subscription.Customer, + organization.GetProductUsageType() == ProductUsageType.Business); + + if (subscription.Customer.Discount != null) + { + options.Coupon = subscription.Customer.Discount.Coupon.Id; + } + + var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + var items = new List(); + + if (update.PasswordManager?.Seats != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.HasNonSeatBasedPasswordManagerPlan() + ? currentPlan.PasswordManager.StripePlanId + : currentPlan.PasswordManager.StripeSeatPlanId, + Quantity = update.PasswordManager.Seats + }); + } + + if (update.PasswordManager?.AdditionalStorage is > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.PasswordManager.StripeStoragePlanId, + Quantity = update.PasswordManager.AdditionalStorage + }); + } + + if (update.SecretsManager?.Seats is > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.SecretsManager.StripeSeatPlanId, + Quantity = update.SecretsManager.Seats + }); + + if (update.SecretsManager.AdditionalServiceAccounts is > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.SecretsManager.StripeServiceAccountPlanId, + Quantity = update.SecretsManager.AdditionalServiceAccounts + }); + } + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + }); + + private static (decimal, decimal) GetAmounts(Invoice invoice) => ( + Convert.ToDecimal(invoice.Tax) / 100, + Convert.ToDecimal(invoice.Total) / 100); + + private static InvoiceCreatePreviewOptions GetBaseOptions( + OneOf addressChoice, + bool businessUse) + { + var country = addressChoice.Match( + customer => customer.Address.Country, + billingAddress => billingAddress.Country + ); + + var postalCode = addressChoice.Match( + customer => customer.Address.PostalCode, + billingAddress => billingAddress.PostalCode); + + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, + Currency = "usd", + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions { Country = country, PostalCode = postalCode }, + TaxExempt = businessUse && country != CountryAbbreviations.UnitedStates + ? TaxExempt.Reverse + : TaxExempt.None + } + }; + + var taxId = addressChoice.Match( + customer => + { + var taxId = customer.TaxIds?.FirstOrDefault(); + return taxId != null ? new TaxID(taxId.Type, taxId.Value) : null; + }, + billingAddress => billingAddress.TaxId); + + if (taxId == null) + { + return options; + } + + options.CustomerDetails.TaxIds = + [ + new InvoiceCustomerDetailsTaxIdOptions { Type = taxId.Code, Value = taxId.Value } + ]; + + if (taxId.Code == TaxIdType.SpanishNIF) + { + options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions + { + Type = TaxIdType.EUVAT, + Value = $"ES{taxId.Value}" + }); + } + + return options; + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs similarity index 87% rename from src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs rename to src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs index ffeee39c07..fde95f2e70 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs +++ b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs @@ -1,16 +1,20 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; -namespace Bit.Core.OrganizationFeatures.OrganizationLicenses; +namespace Bit.Core.Billing.Organizations.Commands; + +public interface IUpdateOrganizationLicenseCommand +{ + Task UpdateLicenseAsync(SelfHostedOrganizationDetails selfHostedOrganization, + OrganizationLicense license, Organization? currentOrganizationUsingLicenseKey); +} public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseCommand { diff --git a/src/Core/Billing/Entities/OrganizationInstallation.cs b/src/Core/Billing/Organizations/Entities/OrganizationInstallation.cs similarity index 90% rename from src/Core/Billing/Entities/OrganizationInstallation.cs rename to src/Core/Billing/Organizations/Entities/OrganizationInstallation.cs index 4332afd44a..98930ae805 100644 --- a/src/Core/Billing/Entities/OrganizationInstallation.cs +++ b/src/Core/Billing/Organizations/Entities/OrganizationInstallation.cs @@ -1,9 +1,7 @@ using Bit.Core.Entities; using Bit.Core.Utilities; -namespace Bit.Core.Billing.Entities; - -#nullable enable +namespace Bit.Core.Billing.Organizations.Entities; public class OrganizationInstallation : ITableObject { diff --git a/src/Core/Models/Business/OrganizationLicense.cs b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs similarity index 91% rename from src/Core/Models/Business/OrganizationLicense.cs rename to src/Core/Billing/Organizations/Models/OrganizationLicense.cs index e8c04b1277..83789be2f3 100644 --- a/src/Core/Models/Business/OrganizationLicense.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs @@ -1,4 +1,7 @@ -using System.Reflection; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Reflection; using System.Security.Claims; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -7,11 +10,13 @@ using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Services; using Bit.Core.Enums; -using Bit.Core.Services; +using Bit.Core.Models.Business; using Bit.Core.Settings; -namespace Bit.Core.Models.Business; +namespace Bit.Core.Billing.Organizations.Models; public class OrganizationLicense : ILicense { @@ -51,7 +56,7 @@ public class OrganizationLicense : ILicense ILicensingService licenseService, int? version = null) { Version = version.GetValueOrDefault(CurrentLicenseFileVersion); // TODO: Remember to change the constant - LicenseType = Enums.LicenseType.Organization; + LicenseType = Core.Enums.LicenseType.Organization; LicenseKey = org.LicenseKey; InstallationId = installationId; Id = org.Id; @@ -91,50 +96,13 @@ public class OrganizationLicense : ILicense AllowAdminAccessToAllCollectionItems = org.AllowAdminAccessToAllCollectionItems; // - if (subscriptionInfo?.Subscription == null) - { - if (org.PlanType == PlanType.Custom && org.ExpirationDate.HasValue) - { - Expires = Refresh = org.ExpirationDate.Value; - Trial = false; - } - else - { - Expires = Refresh = Issued.AddDays(7); - Trial = true; - } - } - else if (subscriptionInfo.Subscription.TrialEndDate.HasValue && - subscriptionInfo.Subscription.TrialEndDate.Value > DateTime.UtcNow) - { - Expires = Refresh = subscriptionInfo.Subscription.TrialEndDate.Value; - Trial = true; - } - else - { - if (org.ExpirationDate.HasValue && org.ExpirationDate.Value < DateTime.UtcNow) - { - // expired - Expires = Refresh = org.ExpirationDate.Value; - } - else if (subscriptionInfo?.Subscription?.PeriodDuration != null && - subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180)) - { - Refresh = DateTime.UtcNow.AddDays(30); - Expires = subscriptionInfo.Subscription.PeriodEndDate?.AddDays(Constants - .OrganizationSelfHostSubscriptionGracePeriodDays); - ExpirationWithoutGracePeriod = subscriptionInfo.Subscription.PeriodEndDate; - } - else - { - Expires = org.ExpirationDate.HasValue ? org.ExpirationDate.Value.AddMonths(11) : Issued.AddYears(1); - Refresh = DateTime.UtcNow - Expires > TimeSpan.FromDays(30) ? DateTime.UtcNow.AddDays(30) : Expires; - } - - Trial = false; - } - UseAdminSponsoredFamilies = org.UseAdminSponsoredFamilies; + + Expires = org.CalculateFreshExpirationDate(subscriptionInfo, Issued); + Refresh = org.CalculateFreshRefreshDate(subscriptionInfo, Issued); + ExpirationWithoutGracePeriod = org.CalculateFreshExpirationDateWithoutGracePeriod(subscriptionInfo); + Trial = org.CalculateIsTrialing(subscriptionInfo); + Hash = Convert.ToBase64String(ComputeHash()); Signature = Convert.ToBase64String(licenseService.SignLicense(this)); } @@ -260,7 +228,7 @@ public class OrganizationLicense : ILicense !p.Name.Equals(nameof(UseAdminSponsoredFamilies)) && !p.Name.Equals(nameof(UseOrganizationDomains))) .OrderBy(p => p.Name) - .Select(p => $"{p.Name}:{Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") + .Select(p => $"{p.Name}:{Core.Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") .Aggregate((c, n) => $"{c}|{n}"); data = $"license:organization|{props}"; } @@ -312,7 +280,7 @@ public class OrganizationLicense : ILicense } var licenseType = claimsPrincipal.GetValue(nameof(LicenseType)); - if (licenseType != Enums.LicenseType.Organization) + if (licenseType != Core.Enums.LicenseType.Organization) { errorMessages.AppendLine("Premium licenses cannot be applied to an organization. " + "Upload this license from your personal account settings page."); @@ -393,7 +361,7 @@ public class OrganizationLicense : ILicense errorMessages.AppendLine("The license does not allow for on-premise hosting of organizations."); } - if (LicenseType != null && LicenseType != Enums.LicenseType.Organization) + if (LicenseType != null && LicenseType != Core.Enums.LicenseType.Organization) { errorMessages.AppendLine("Premium licenses cannot be applied to an organization. " + "Upload this license from your personal account settings page."); diff --git a/src/Core/Billing/Models/OrganizationMetadata.cs b/src/Core/Billing/Organizations/Models/OrganizationMetadata.cs similarity index 92% rename from src/Core/Billing/Models/OrganizationMetadata.cs rename to src/Core/Billing/Organizations/Models/OrganizationMetadata.cs index 0f2bf9a454..2bcd213dbf 100644 --- a/src/Core/Billing/Models/OrganizationMetadata.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationMetadata.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Organizations.Models; public record OrganizationMetadata( bool IsEligibleForSelfHost, diff --git a/src/Core/Billing/Models/Sales/OrganizationSale.cs b/src/Core/Billing/Organizations/Models/OrganizationSale.cs similarity index 96% rename from src/Core/Billing/Models/Sales/OrganizationSale.cs rename to src/Core/Billing/Organizations/Models/OrganizationSale.cs index c8ccb0f9a1..f1f3a636b7 100644 --- a/src/Core/Billing/Models/Sales/OrganizationSale.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationSale.cs @@ -1,11 +1,11 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Tax.Models; using Bit.Core.Models.Business; -namespace Bit.Core.Billing.Models.Sales; - -#nullable enable +namespace Bit.Core.Billing.Organizations.Models; public class OrganizationSale { diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs new file mode 100644 index 0000000000..7781f91960 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs @@ -0,0 +1,23 @@ +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Organizations.Models; + +public record OrganizationSubscriptionPlanChange +{ + public ProductTierType Tier { get; init; } + public PlanCadenceType Cadence { get; init; } + + public PlanType PlanType => + // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault + Tier switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => Cadence == PlanCadenceType.Monthly + ? PlanType.TeamsMonthly + : PlanType.TeamsAnnually, + ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly + ? PlanType.EnterpriseMonthly + : PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException("Cannot change an Organization subscription to a tier that isn't Families, Teams or Enterprise.") + }; +} diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs new file mode 100644 index 0000000000..6691d69848 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs @@ -0,0 +1,39 @@ +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Organizations.Models; + +public record OrganizationSubscriptionPurchase +{ + public ProductTierType Tier { get; init; } + public PlanCadenceType Cadence { get; init; } + public required PasswordManagerSelections PasswordManager { get; init; } + public SecretsManagerSelections? SecretsManager { get; init; } + + public PlanType PlanType => + // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault + Tier switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => Cadence == PlanCadenceType.Monthly + ? PlanType.TeamsMonthly + : PlanType.TeamsAnnually, + ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly + ? PlanType.EnterpriseMonthly + : PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException("Cannot purchase an Organization subscription that isn't Families, Teams or Enterprise.") + }; + + public record PasswordManagerSelections + { + public int Seats { get; init; } + public int AdditionalStorage { get; init; } + public bool Sponsored { get; init; } + } + + public record SecretsManagerSelections + { + public int Seats { get; init; } + public int AdditionalServiceAccounts { get; init; } + public bool Standalone { get; init; } + } +} diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs new file mode 100644 index 0000000000..810f292c81 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.Billing.Organizations.Models; + +public record OrganizationSubscriptionUpdate +{ + public PasswordManagerSelections? PasswordManager { get; init; } + public SecretsManagerSelections? SecretsManager { get; init; } + + public record PasswordManagerSelections + { + public int? Seats { get; init; } + public int? AdditionalStorage { get; init; } + } + + public record SecretsManagerSelections + { + public int? Seats { get; init; } + public int? AdditionalServiceAccounts { get; init; } + } +} diff --git a/src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs b/src/Core/Billing/Organizations/Models/OrganizationWarnings.cs similarity index 82% rename from src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs rename to src/Core/Billing/Organizations/Models/OrganizationWarnings.cs index e124bdc318..cf386fb317 100644 --- a/src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationWarnings.cs @@ -1,11 +1,11 @@ -#nullable enable -namespace Bit.Api.Billing.Models.Responses.Organizations; +namespace Bit.Core.Billing.Organizations.Models; -public record OrganizationWarningsResponse +public record OrganizationWarnings { public FreeTrialWarning? FreeTrial { get; set; } public InactiveSubscriptionWarning? InactiveSubscription { get; set; } public ResellerRenewalWarning? ResellerRenewal { get; set; } + public TaxIdWarning? TaxId { get; set; } public record FreeTrialWarning { @@ -40,4 +40,9 @@ public record OrganizationWarningsResponse public required DateTime SuspensionDate { get; set; } } } + + public record TaxIdWarning + { + public required string Type { get; set; } + } } diff --git a/src/Core/Billing/Models/Business/SponsorOrganizationSubscriptionUpdate.cs b/src/Core/Billing/Organizations/Models/SponsorOrganizationSubscriptionUpdate.cs similarity index 94% rename from src/Core/Billing/Models/Business/SponsorOrganizationSubscriptionUpdate.cs rename to src/Core/Billing/Organizations/Models/SponsorOrganizationSubscriptionUpdate.cs index 830105e373..ee603c67e0 100644 --- a/src/Core/Billing/Models/Business/SponsorOrganizationSubscriptionUpdate.cs +++ b/src/Core/Billing/Organizations/Models/SponsorOrganizationSubscriptionUpdate.cs @@ -1,7 +1,10 @@ -using Bit.Core.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Business; using Stripe; -namespace Bit.Core.Billing.Models.Business; +namespace Bit.Core.Billing.Organizations.Models; public class SponsorOrganizationSubscriptionUpdate : SubscriptionUpdate { diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs b/src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs similarity index 79% rename from src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs rename to src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs index 44edde1495..f00bc00356 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs @@ -1,15 +1,25 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.Platform.Installations; using Bit.Core.Services; -namespace Bit.Core.OrganizationFeatures.OrganizationLicenses; +namespace Bit.Core.Billing.Organizations.Queries; -public class CloudGetOrganizationLicenseQuery : ICloudGetOrganizationLicenseQuery +public interface IGetCloudOrganizationLicenseQuery +{ + Task GetLicenseAsync(Organization organization, Guid installationId, + int? version = null); +} + +public class GetCloudOrganizationLicenseQuery : IGetCloudOrganizationLicenseQuery { private readonly IInstallationRepository _installationRepository; private readonly IPaymentService _paymentService; @@ -17,7 +27,7 @@ public class CloudGetOrganizationLicenseQuery : ICloudGetOrganizationLicenseQuer private readonly IProviderRepository _providerRepository; private readonly IFeatureService _featureService; - public CloudGetOrganizationLicenseQuery( + public GetCloudOrganizationLicenseQuery( IInstallationRepository installationRepository, IPaymentService paymentService, ILicensingService licensingService, diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs new file mode 100644 index 0000000000..f33814f1cf --- /dev/null +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -0,0 +1,308 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Context; +using Bit.Core.Services; +using Stripe; +using Stripe.Tax; + +namespace Bit.Core.Billing.Organizations.Queries; + +using static Core.Constants; +using static StripeConstants; +using FreeTrialWarning = OrganizationWarnings.FreeTrialWarning; +using InactiveSubscriptionWarning = OrganizationWarnings.InactiveSubscriptionWarning; +using ResellerRenewalWarning = OrganizationWarnings.ResellerRenewalWarning; +using TaxIdWarning = OrganizationWarnings.TaxIdWarning; + +public interface IGetOrganizationWarningsQuery +{ + Task Run( + Organization organization); +} + +public class GetOrganizationWarningsQuery( + ICurrentContext currentContext, + IProviderRepository providerRepository, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService) : IGetOrganizationWarningsQuery +{ + public async Task Run( + Organization organization) + { + var warnings = new OrganizationWarnings(); + + var subscription = + await subscriberService.GetSubscription(organization, + new SubscriptionGetOptions { Expand = ["customer.tax_ids", "latest_invoice", "test_clock"] }); + + if (subscription == null) + { + return warnings; + } + + warnings.FreeTrial = await GetFreeTrialWarningAsync(organization, subscription); + + var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); + + warnings.InactiveSubscription = await GetInactiveSubscriptionWarningAsync(organization, provider, subscription); + + warnings.ResellerRenewal = await GetResellerRenewalWarningAsync(provider, subscription); + + warnings.TaxId = await GetTaxIdWarningAsync(organization, subscription.Customer, provider); + + return warnings; + } + + private async Task GetFreeTrialWarningAsync( + Organization organization, + Subscription subscription) + { + if (!await currentContext.EditSubscription(organization.Id)) + { + return null; + } + + if (subscription is not + { + Status: SubscriptionStatus.Trialing, + TrialEnd: not null, + Customer: not null + }) + { + return null; + } + + var customer = subscription.Customer; + + var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(organization); + + var hasPaymentMethod = + !string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || + !string.IsNullOrEmpty(customer.DefaultSourceId) || + hasUnverifiedBankAccount || + customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId); + + if (hasPaymentMethod) + { + return null; + } + + var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + + var remainingTrialDays = (int)Math.Ceiling((subscription.TrialEnd.Value - now).TotalDays); + + return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays }; + } + + private async Task GetInactiveSubscriptionWarningAsync( + Organization organization, + Provider? provider, + Subscription subscription) + { + // If the organization is enabled or the subscription is active, don't return a warning. + if (organization.Enabled || subscription is not + { + Status: SubscriptionStatus.Unpaid or SubscriptionStatus.Canceled + }) + { + return null; + } + + // If the organization is managed by a provider, return a warning asking them to contact the provider. + if (provider != null) + { + return new InactiveSubscriptionWarning { Resolution = "contact_provider" }; + } + + var isOrganizationOwner = await currentContext.OrganizationOwner(organization.Id); + + /* If the organization is not managed by a provider and this user is the owner, return a warning based + on the subscription status. */ + if (isOrganizationOwner) + { + return subscription.Status switch + { + SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning + { + Resolution = "add_payment_method" + }, + SubscriptionStatus.Canceled => new InactiveSubscriptionWarning + { + Resolution = "resubscribe" + }, + _ => null + }; + } + + // Otherwise, return a warning asking them to contact the owner. + return new InactiveSubscriptionWarning { Resolution = "contact_owner" }; + } + + private async Task GetResellerRenewalWarningAsync( + Provider? provider, + Subscription subscription) + { + if (provider is not + { + Type: ProviderType.Reseller + }) + { + return null; + } + + if (subscription.CollectionMethod != CollectionMethod.SendInvoice) + { + return null; + } + + var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (subscription is + { + Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active, + LatestInvoice: null or { Status: InvoiceStatus.Paid } + } && (subscription.CurrentPeriodEnd - now).TotalDays <= 14) + { + return new ResellerRenewalWarning + { + Type = "upcoming", + Upcoming = new ResellerRenewalWarning.UpcomingRenewal + { + RenewalDate = subscription.CurrentPeriodEnd + } + }; + } + + if (subscription is + { + Status: SubscriptionStatus.Active, + LatestInvoice: { Status: InvoiceStatus.Open, DueDate: not null } + } && subscription.LatestInvoice.DueDate > now) + { + return new ResellerRenewalWarning + { + Type = "issued", + Issued = new ResellerRenewalWarning.IssuedRenewal + { + IssuedDate = subscription.LatestInvoice.Created, + DueDate = subscription.LatestInvoice.DueDate.Value + } + }; + } + + // ReSharper disable once InvertIf + if (subscription.Status == SubscriptionStatus.PastDue) + { + var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions + { + Query = $"subscription:'{subscription.Id}' status:'open'" + }); + + var earliestOverdueInvoice = openInvoices + .Where(invoice => invoice.DueDate != null && invoice.DueDate < now) + .MinBy(invoice => invoice.Created); + + if (earliestOverdueInvoice != null) + { + return new ResellerRenewalWarning + { + Type = "past_due", + PastDue = new ResellerRenewalWarning.PastDueRenewal + { + SuspensionDate = earliestOverdueInvoice.DueDate!.Value.AddDays(30) + } + }; + } + } + + return null; + } + + private async Task GetTaxIdWarningAsync( + Organization organization, + Customer customer, + Provider? provider) + { + if (customer.Address?.Country == CountryAbbreviations.UnitedStates) + { + return null; + } + + var productTier = organization.PlanType.GetProductTier(); + + // Only business tier customers can have tax IDs + if (productTier is not ProductTierType.Teams and not ProductTierType.Enterprise) + { + return null; + } + + // Only an organization owner can update a tax ID + if (!await currentContext.OrganizationOwner(organization.Id)) + { + return null; + } + + if (provider != null) + { + return null; + } + + // Get active and scheduled registrations + var registrations = (await Task.WhenAll( + stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }), + stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled }))) + .SelectMany(registrations => registrations.Data); + + // Find the matching registration for the customer + var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address?.Country); + + // If we're not registered in their country, we don't need a warning + if (registration == null) + { + return null; + } + + var taxId = customer.TaxIds.FirstOrDefault(); + + return taxId switch + { + // Customer's tax ID is missing + null => new TaxIdWarning { Type = "tax_id_missing" }, + // Not sure if this case is valid, but Stripe says this property is nullable + not null when taxId.Verification == null => null, + // Customer's tax ID is pending verification + not null when taxId.Verification.Status == TaxIdVerificationStatus.Pending => new TaxIdWarning { Type = "tax_id_pending_verification" }, + // Customer's tax ID failed verification + not null when taxId.Verification.Status == TaxIdVerificationStatus.Unverified => new TaxIdWarning { Type = "tax_id_failed_verification" }, + _ => null + }; + } + + private async Task HasUnverifiedBankAccountAsync( + Organization organization) + { + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id); + + if (string.IsNullOrEmpty(setupIntentId)) + { + return false; + } + + var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + { + Expand = ["payment_method"] + }); + + return setupIntent.IsUnverifiedBankAccount(); + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/SelfHosted/SelfHostedGetOrganizationLicenseQuery.cs b/src/Core/Billing/Organizations/Queries/GetSelfHostedOrganizationLicenseQuery.cs similarity index 75% rename from src/Core/OrganizationFeatures/OrganizationLicenses/SelfHosted/SelfHostedGetOrganizationLicenseQuery.cs rename to src/Core/Billing/Organizations/Queries/GetSelfHostedOrganizationLicenseQuery.cs index 89ea53fc20..ad6c2a2cdf 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/SelfHosted/SelfHostedGetOrganizationLicenseQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetSelfHostedOrganizationLicenseQuery.cs @@ -1,22 +1,29 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Api.OrganizationLicenses; -using Bit.Core.Models.Business; using Bit.Core.Models.OrganizationConnectionConfigs; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Extensions.Logging; -namespace Bit.Core.OrganizationFeatures.OrganizationLicenses; +namespace Bit.Core.Billing.Organizations.Queries; -public class SelfHostedGetOrganizationLicenseQuery : BaseIdentityClientService, ISelfHostedGetOrganizationLicenseQuery +public interface IGetSelfHostedOrganizationLicenseQuery +{ + Task GetLicenseAsync(Organization organization, OrganizationConnection billingSyncConnection); +} + +public class GetSelfHostedOrganizationLicenseQuery : BaseIdentityClientService, IGetSelfHostedOrganizationLicenseQuery { private readonly IGlobalSettings _globalSettings; - public SelfHostedGetOrganizationLicenseQuery(IHttpClientFactory httpFactory, IGlobalSettings globalSettings, ILogger logger, ICurrentContext currentContext) + public GetSelfHostedOrganizationLicenseQuery(IHttpClientFactory httpFactory, IGlobalSettings globalSettings, ILogger logger, ICurrentContext currentContext) : base( httpFactory, globalSettings.Installation.ApiUri, diff --git a/src/Core/Billing/Repositories/IOrganizationInstallationRepository.cs b/src/Core/Billing/Organizations/Repositories/IOrganizationInstallationRepository.cs similarity index 74% rename from src/Core/Billing/Repositories/IOrganizationInstallationRepository.cs rename to src/Core/Billing/Organizations/Repositories/IOrganizationInstallationRepository.cs index 05710d3966..cd96ab747e 100644 --- a/src/Core/Billing/Repositories/IOrganizationInstallationRepository.cs +++ b/src/Core/Billing/Organizations/Repositories/IOrganizationInstallationRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Organizations.Entities; using Bit.Core.Repositories; -namespace Bit.Core.Billing.Repositories; +namespace Bit.Core.Billing.Organizations.Repositories; public interface IOrganizationInstallationRepository : IRepository { diff --git a/src/Core/Billing/Services/IOrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs similarity index 69% rename from src/Core/Billing/Services/IOrganizationBillingService.cs rename to src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs index 5f7d33f118..d34bd86e7b 100644 --- a/src/Core/Billing/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs @@ -1,11 +1,10 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Tax.Models; -namespace Bit.Core.Billing.Services; - -#nullable enable +namespace Bit.Core.Billing.Organizations.Services; public interface IOrganizationBillingService { @@ -46,4 +45,15 @@ public interface IOrganizationBillingService Organization organization, TokenizedPaymentSource tokenizedPaymentSource, TaxInformation taxInformation); + + /// + /// Updates the subscription with new plan frequencies and changes the collection method to charge_automatically if a valid payment method exists. + /// Validates that the customer has a payment method attached before switching to automatic charging. + /// Handles both Password Manager and Secrets Manager subscription items separately to ensure billing interval compatibility. + /// + /// The Organization whose subscription to update. + /// The Stripe price/plan for the new Password Manager and secrets manager. + /// Thrown when the is . + /// Thrown when no payment method is found for the customer, no plan IDs are provided, or subscription update fails. + Task UpdateSubscriptionPlanFrequency(Organization organization, PlanType newPlanType); } diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs similarity index 84% rename from src/Core/Billing/Services/Implementations/OrganizationBillingService.cs rename to src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 725e274fa2..ce8a9a877b 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -5,7 +5,9 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; @@ -16,18 +18,14 @@ using Bit.Core.Settings; using Braintree; using Microsoft.Extensions.Logging; using Stripe; - using static Bit.Core.Billing.Utilities; using Customer = Stripe.Customer; using Subscription = Stripe.Subscription; -namespace Bit.Core.Billing.Services.Implementations; - -#nullable enable +namespace Bit.Core.Billing.Organizations.Services; public class OrganizationBillingService( IBraintreeGateway braintreeGateway, - IFeatureService featureService, IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, @@ -146,6 +144,55 @@ public class OrganizationBillingService( { await subscriberService.UpdatePaymentSource(organization, tokenizedPaymentSource); await subscriberService.UpdateTaxInformation(organization, taxInformation); + await UpdateMissingPaymentMethodBehaviourAsync(organization); + } + } + + public async Task UpdateSubscriptionPlanFrequency( + Organization organization, PlanType newPlanType) + { + ArgumentNullException.ThrowIfNull(organization); + + var subscription = await subscriberService.GetSubscriptionOrThrow(organization); + var subscriptionItems = subscription.Items.Data; + + var newPlan = await pricingClient.GetPlanOrThrow(newPlanType); + var oldPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + // Build the subscription update options + var subscriptionItemOptions = new List(); + foreach (var item in subscriptionItems) + { + var subscriptionItemOption = new SubscriptionItemOptions + { + Id = item.Id, + Quantity = item.Quantity, + Price = item.Price.Id == oldPlan.SecretsManager.StripeSeatPlanId ? newPlan.SecretsManager.StripeSeatPlanId : newPlan.PasswordManager.StripeSeatPlanId + }; + + subscriptionItemOptions.Add(subscriptionItemOption); + } + var updateOptions = new SubscriptionUpdateOptions + { + Items = subscriptionItemOptions, + ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations + }; + + try + { + // Update the subscription in Stripe + await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, updateOptions); + organization.PlanType = newPlan.Type; + await organizationRepository.ReplaceAsync(organization); + } + catch (StripeException stripeException) + { + logger.LogError(stripeException, "Failed to update subscription plan for subscriber ({SubscriberID}): {Error}", + organization.Id, stripeException.Message); + + throw new BillingException( + message: "An error occurred while updating the subscription plan", + innerException: stripeException); } } @@ -225,12 +272,10 @@ public class OrganizationBillingService( ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately }; - var setNonUSBusinessUseToReverseCharge = - featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); - if (setNonUSBusinessUseToReverseCharge && - planType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families && - customerSetup.TaxInformation.Country != "US") + + if (planType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families && + customerSetup.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates) { customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; } @@ -338,7 +383,7 @@ public class OrganizationBillingService( { case PaymentMethodType.BankAccount: { - await setupIntentCache.Remove(organization.Id); + await setupIntentCache.RemoveSetupIntentForSubscriber(organization.Id); break; } case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): @@ -443,24 +488,10 @@ public class OrganizationBillingService( }; } - var setNonUSBusinessUseToReverseCharge = - featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); - - if (setNonUSBusinessUseToReverseCharge && customer.HasBillingLocation()) + if (customer.HasBillingLocation()) { subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } - else if (customer.HasRecognizedTaxLocation()) - { - subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = - subscriptionSetup.PlanType.GetProductTier() == ProductTierType.Families || - customer.Address.Country == "US" || - customer.TaxIds.Any() - }; - } - return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); } @@ -471,9 +502,7 @@ public class OrganizationBillingService( var customer = await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax", "tax_ids"] }); - var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); - - if (!setNonUSBusinessUseToReverseCharge || subscriptionSetup.PlanType.GetProductTier() is + if (subscriptionSetup.PlanType.GetProductTier() is not (ProductTierType.Teams or ProductTierType.TeamsStarter or ProductTierType.Enterprise)) @@ -485,14 +514,14 @@ public class OrganizationBillingService( customer = customer switch { - { Address.Country: not "US", TaxExempt: not StripeConstants.TaxExempt.Reverse } => await + { Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not StripeConstants.TaxExempt.Reverse } => await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Expand = expansions, TaxExempt = StripeConstants.TaxExempt.Reverse }), - { Address.Country: "US", TaxExempt: StripeConstants.TaxExempt.Reverse } => await + { Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: StripeConstants.TaxExempt.Reverse } => await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { @@ -546,5 +575,24 @@ public class OrganizationBillingService( return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); } + private async Task UpdateMissingPaymentMethodBehaviourAsync(Organization organization) + { + var subscription = await subscriberService.GetSubscriptionOrThrow(organization); + if (subscription.TrialSettings?.EndBehavior?.MissingPaymentMethod == StripeConstants.MissingPaymentMethodBehaviorOptions.Cancel) + { + var options = new SubscriptionUpdateOptions + { + TrialSettings = new SubscriptionTrialSettingsOptions + { + EndBehavior = new SubscriptionTrialSettingsEndBehaviorOptions + { + MissingPaymentMethod = StripeConstants.MissingPaymentMethodBehaviorOptions.CreateInvoice + } + } + }; + await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, options); + } + } + #endregion } diff --git a/src/Core/Billing/Payment/Clients/BitPayClient.cs b/src/Core/Billing/Payment/Clients/BitPayClient.cs new file mode 100644 index 0000000000..2cb8fb66ef --- /dev/null +++ b/src/Core/Billing/Payment/Clients/BitPayClient.cs @@ -0,0 +1,24 @@ +using Bit.Core.Settings; +using BitPayLight; +using BitPayLight.Models.Invoice; + +namespace Bit.Core.Billing.Payment.Clients; + +public interface IBitPayClient +{ + Task GetInvoice(string invoiceId); + Task CreateInvoice(Invoice invoice); +} + +public class BitPayClient( + GlobalSettings globalSettings) : IBitPayClient +{ + private readonly BitPay _bitPay = new( + globalSettings.BitPay.Token, globalSettings.BitPay.Production ? Env.Prod : Env.Test); + + public Task GetInvoice(string invoiceId) + => _bitPay.GetInvoice(invoiceId); + + public Task CreateInvoice(Invoice invoice) + => _bitPay.CreateInvoice(invoice); +} diff --git a/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs b/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs new file mode 100644 index 0000000000..a86f0e3ada --- /dev/null +++ b/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs @@ -0,0 +1,60 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Payment.Clients; +using Bit.Core.Entities; +using Bit.Core.Settings; +using BitPayLight.Models.Invoice; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Billing.Payment.Commands; + +public interface ICreateBitPayInvoiceForCreditCommand +{ + Task> Run( + ISubscriber subscriber, + decimal amount, + string redirectUrl); +} + +public class CreateBitPayInvoiceForCreditCommand( + IBitPayClient bitPayClient, + GlobalSettings globalSettings, + ILogger logger) : BaseBillingCommand(logger), ICreateBitPayInvoiceForCreditCommand +{ + protected override Conflict DefaultConflict => new("We had a problem applying your account credit. Please contact support for assistance."); + + public Task> Run( + ISubscriber subscriber, + decimal amount, + string redirectUrl) => HandleAsync(async () => + { + var (name, email, posData) = GetSubscriberInformation(subscriber); + + var invoice = new Invoice + { + Buyer = new Buyer { Email = email, Name = name }, + Currency = "USD", + ExtendedNotifications = true, + FullNotifications = true, + ItemDesc = "Bitwarden", + NotificationUrl = globalSettings.BitPay.NotificationUrl, + PosData = posData, + Price = Convert.ToDouble(amount), + RedirectUrl = redirectUrl + }; + + var created = await bitPayClient.CreateInvoice(invoice); + return created.Url; + }); + + private static (string? Name, string? Email, string POSData) GetSubscriberInformation( + ISubscriber subscriber) => subscriber switch + { + User user => (user.Email, user.Email, $"userId:{user.Id},accountCredit:1"), + Organization organization => (organization.Name, organization.BillingEmail, + $"organizationId:{organization.Id},accountCredit:1"), + Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},accountCredit:1"), + _ => throw new ArgumentOutOfRangeException(nameof(subscriber)) + }; +} diff --git a/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs new file mode 100644 index 0000000000..f4eca40cae --- /dev/null +++ b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs @@ -0,0 +1,141 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Payment.Commands; + +public interface IUpdateBillingAddressCommand +{ + Task> Run( + ISubscriber subscriber, + BillingAddress billingAddress); +} + +public class UpdateBillingAddressCommand( + ILogger logger, + ISubscriberService subscriberService, + IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IUpdateBillingAddressCommand +{ + protected override Conflict DefaultConflict => + new("We had a problem updating your billing address. Please contact support for assistance."); + + public Task> Run( + ISubscriber subscriber, + BillingAddress billingAddress) => HandleAsync(async () => + { + if (string.IsNullOrEmpty(subscriber.GatewayCustomerId)) + { + await subscriberService.CreateStripeCustomer(subscriber); + } + + return subscriber.GetProductUsageType() switch + { + ProductUsageType.Personal => await UpdatePersonalBillingAddressAsync(subscriber, billingAddress), + ProductUsageType.Business => await UpdateBusinessBillingAddressAsync(subscriber, billingAddress) + }; + }); + + private async Task> UpdatePersonalBillingAddressAsync( + ISubscriber subscriber, + BillingAddress billingAddress) + { + var customer = + await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, + new CustomerUpdateOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode, + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + State = billingAddress.State + }, + Expand = ["subscriptions"] + }); + + await EnableAutomaticTaxAsync(subscriber, customer); + + return BillingAddress.From(customer.Address); + } + + private async Task> UpdateBusinessBillingAddressAsync( + ISubscriber subscriber, + BillingAddress billingAddress) + { + var customer = + await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, + new CustomerUpdateOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode, + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + State = billingAddress.State + }, + Expand = ["subscriptions", "tax_ids"], + TaxExempt = billingAddress.Country != Core.Constants.CountryAbbreviations.UnitedStates + ? StripeConstants.TaxExempt.Reverse + : StripeConstants.TaxExempt.None + }); + + await EnableAutomaticTaxAsync(subscriber, customer); + + var deleteExistingTaxIds = customer.TaxIds?.Any() ?? false + ? customer.TaxIds.Select(taxId => stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id)).ToList() + : []; + + if (billingAddress.TaxId == null) + { + await Task.WhenAll(deleteExistingTaxIds); + return BillingAddress.From(customer.Address); + } + + var updatedTaxId = await stripeAdapter.TaxIdCreateAsync(customer.Id, + new TaxIdCreateOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value }); + + if (billingAddress.TaxId.Code == StripeConstants.TaxIdType.SpanishNIF) + { + updatedTaxId = await stripeAdapter.TaxIdCreateAsync(customer.Id, + new TaxIdCreateOptions + { + Type = StripeConstants.TaxIdType.EUVAT, + Value = $"ES{billingAddress.TaxId.Value}" + }); + } + + await Task.WhenAll(deleteExistingTaxIds); + + return BillingAddress.From(customer.Address, updatedTaxId); + } + + private async Task EnableAutomaticTaxAsync( + ISubscriber subscriber, + Customer customer) + { + if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId)) + { + var subscription = customer.Subscriptions.FirstOrDefault(subscription => + subscription.Id == subscriber.GatewaySubscriptionId); + + if (subscription is { AutomaticTax.Enabled: false }) + { + await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } + } + } +} diff --git a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs new file mode 100644 index 0000000000..81206b8032 --- /dev/null +++ b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs @@ -0,0 +1,210 @@ +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Braintree; +using Microsoft.Extensions.Logging; +using Stripe; +using Customer = Stripe.Customer; + +namespace Bit.Core.Billing.Payment.Commands; + +public interface IUpdatePaymentMethodCommand +{ + Task> Run( + ISubscriber subscriber, + TokenizedPaymentMethod paymentMethod, + BillingAddress? billingAddress); +} + +public class UpdatePaymentMethodCommand( + IBraintreeGateway braintreeGateway, + IGlobalSettings globalSettings, + ILogger logger, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService) : BaseBillingCommand(logger), IUpdatePaymentMethodCommand +{ + private readonly ILogger _logger = logger; + protected override Conflict DefaultConflict + => new("We had a problem updating your payment method. Please contact support for assistance."); + + public Task> Run( + ISubscriber subscriber, + TokenizedPaymentMethod paymentMethod, + BillingAddress? billingAddress) => HandleAsync(async () => + { + if (string.IsNullOrEmpty(subscriber.GatewayCustomerId)) + { + await subscriberService.CreateStripeCustomer(subscriber); + } + + var customer = await subscriberService.GetCustomer(subscriber); + + var result = paymentMethod.Type switch + { + TokenizablePaymentMethodType.BankAccount => await AddBankAccountAsync(subscriber, customer, paymentMethod.Token), + TokenizablePaymentMethodType.Card => await AddCardAsync(customer, paymentMethod.Token), + TokenizablePaymentMethodType.PayPal => await AddPayPalAsync(subscriber, customer, paymentMethod.Token), + _ => new BadRequest($"Payment method type '{paymentMethod.Type}' is not supported.") + }; + + if (billingAddress != null && customer.Address is not { Country: not null, PostalCode: not null }) + { + await stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + } + }); + } + + return result; + }); + + private async Task> AddBankAccountAsync( + ISubscriber subscriber, + Customer customer, + string token) + { + var setupIntents = await stripeAdapter.SetupIntentList(new SetupIntentListOptions + { + Expand = ["data.payment_method"], + PaymentMethod = token + }); + + switch (setupIntents.Count) + { + case 0: + _logger.LogError("{Command}: Could not find setup intent for subscriber's ({SubscriberID}) bank account", CommandName, subscriber.Id); + return DefaultConflict; + case > 1: + _logger.LogError("{Command}: Found more than one set up intent for subscriber's ({SubscriberID}) bank account", CommandName, subscriber.Id); + return DefaultConflict; + } + + var setupIntent = setupIntents.First(); + + await setupIntentCache.Set(subscriber.Id, setupIntent.Id); + + await UnlinkBraintreeCustomerAsync(customer); + + return MaskedPaymentMethod.From(setupIntent); + } + + private async Task> AddCardAsync( + Customer customer, + string token) + { + var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(token, new PaymentMethodAttachOptions { Customer = customer.Id }); + + await stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions { DefaultPaymentMethod = token } + }); + + await UnlinkBraintreeCustomerAsync(customer); + + return MaskedPaymentMethod.From(paymentMethod.Card); + } + + private async Task> AddPayPalAsync( + ISubscriber subscriber, + Customer customer, + string token) + { + Braintree.Customer braintreeCustomer; + + if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) + { + braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId); + + await ReplaceBraintreePaymentMethodAsync(braintreeCustomer, token); + } + else + { + braintreeCustomer = await CreateBraintreeCustomerAsync(subscriber, token); + + var metadata = new Dictionary + { + [StripeConstants.MetadataKeys.BraintreeCustomerId] = braintreeCustomer.Id + }; + + await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); + } + + var payPalAccount = braintreeCustomer.DefaultPaymentMethod as PayPalAccount; + + return MaskedPaymentMethod.From(payPalAccount!); + } + + private async Task CreateBraintreeCustomerAsync( + ISubscriber subscriber, + string token) + { + var braintreeCustomerId = + subscriber.BraintreeCustomerIdPrefix() + + subscriber.Id.ToString("N").ToLower() + + CoreHelpers.RandomString(3, upper: false, numeric: false); + + var result = await braintreeGateway.Customer.CreateAsync(new CustomerRequest + { + Id = braintreeCustomerId, + CustomFields = new Dictionary + { + [subscriber.BraintreeIdField()] = subscriber.Id.ToString(), + [subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion + }, + Email = subscriber.BillingEmailAddress(), + PaymentMethodNonce = token + }); + + return result.Target; + } + + private async Task ReplaceBraintreePaymentMethodAsync( + Braintree.Customer customer, + string token) + { + var existing = customer.DefaultPaymentMethod; + + var result = await braintreeGateway.PaymentMethod.CreateAsync(new PaymentMethodRequest + { + CustomerId = customer.Id, + PaymentMethodNonce = token + }); + + await braintreeGateway.Customer.UpdateAsync( + customer.Id, + new CustomerRequest { DefaultPaymentMethodToken = result.Target.Token }); + + if (existing != null) + { + await braintreeGateway.PaymentMethod.DeleteAsync(existing.Token); + } + } + + private async Task UnlinkBraintreeCustomerAsync( + Customer customer) + { + if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) + { + var metadata = new Dictionary + { + [StripeConstants.MetadataKeys.RetiredBraintreeCustomerId] = braintreeCustomerId, + [StripeConstants.MetadataKeys.BraintreeCustomerId] = string.Empty + }; + + await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); + } + } +} diff --git a/src/Core/Billing/Payment/Models/BillingAddress.cs b/src/Core/Billing/Payment/Models/BillingAddress.cs new file mode 100644 index 0000000000..39dd1f4121 --- /dev/null +++ b/src/Core/Billing/Payment/Models/BillingAddress.cs @@ -0,0 +1,29 @@ +using Stripe; + +namespace Bit.Core.Billing.Payment.Models; + +public record TaxID(string Code, string Value); + +public record BillingAddress +{ + public required string Country { get; set; } + public required string PostalCode { get; set; } + public string? Line1 { get; set; } + public string? Line2 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public TaxID? TaxId { get; set; } + + public static BillingAddress From(Address address) => new() + { + Country = address.Country, + PostalCode = address.PostalCode, + Line1 = address.Line1, + Line2 = address.Line2, + City = address.City, + State = address.State + }; + + public static BillingAddress From(Address address, TaxId? taxId) => + From(address) with { TaxId = taxId != null ? new TaxID(taxId.Type, taxId.Value) : null }; +} diff --git a/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs b/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs new file mode 100644 index 0000000000..d30c27ee41 --- /dev/null +++ b/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Braintree; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Payment.Models; + +public record MaskedBankAccount +{ + public required string BankName { get; init; } + public required string Last4 { get; init; } + public string? HostedVerificationUrl { get; init; } + public string Type => "bankAccount"; +} + +public record MaskedCard +{ + public required string Brand { get; init; } + public required string Last4 { get; init; } + public required string Expiration { get; init; } + public string Type => "card"; +} + +public record MaskedPayPalAccount +{ + public required string Email { get; init; } + public string Type => "payPal"; +} + +[JsonConverter(typeof(MaskedPaymentMethodJsonConverter))] +public class MaskedPaymentMethod(OneOf input) + : OneOfBase(input) +{ + public static implicit operator MaskedPaymentMethod(MaskedBankAccount bankAccount) => new(bankAccount); + public static implicit operator MaskedPaymentMethod(MaskedCard card) => new(card); + public static implicit operator MaskedPaymentMethod(MaskedPayPalAccount payPal) => new(payPal); + + public static MaskedPaymentMethod From(BankAccount bankAccount) => new MaskedBankAccount + { + BankName = bankAccount.BankName, + Last4 = bankAccount.Last4 + }; + + public static MaskedPaymentMethod From(Card card) => new MaskedCard + { + Brand = card.Brand.ToLower(), + Last4 = card.Last4, + Expiration = $"{card.ExpMonth:00}/{card.ExpYear}" + }; + + public static MaskedPaymentMethod From(PaymentMethodCard card) => new MaskedCard + { + Brand = card.Brand.ToLower(), + Last4 = card.Last4, + Expiration = $"{card.ExpMonth:00}/{card.ExpYear}" + }; + + public static MaskedPaymentMethod From(SetupIntent setupIntent) => new MaskedBankAccount + { + BankName = setupIntent.PaymentMethod.UsBankAccount.BankName, + Last4 = setupIntent.PaymentMethod.UsBankAccount.Last4, + HostedVerificationUrl = setupIntent.NextAction?.VerifyWithMicrodeposits?.HostedVerificationUrl + }; + + public static MaskedPaymentMethod From(SourceCard sourceCard) => new MaskedCard + { + Brand = sourceCard.Brand.ToLower(), + Last4 = sourceCard.Last4, + Expiration = $"{sourceCard.ExpMonth:00}/{sourceCard.ExpYear}" + }; + + public static MaskedPaymentMethod From(PaymentMethodUsBankAccount bankAccount) => new MaskedBankAccount + { + BankName = bankAccount.BankName, + Last4 = bankAccount.Last4 + }; + + public static MaskedPaymentMethod From(PayPalAccount payPalAccount) => new MaskedPayPalAccount { Email = payPalAccount.Email }; +} + +public class MaskedPaymentMethodJsonConverter : JsonConverter +{ + private const string _typePropertyName = nameof(MaskedBankAccount.Type); + + public override MaskedPaymentMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var element = JsonElement.ParseValue(ref reader); + + if (!element.TryGetProperty(options.PropertyNamingPolicy?.ConvertName(_typePropertyName) ?? _typePropertyName, out var typeProperty)) + { + throw new JsonException( + $"Failed to deserialize {nameof(MaskedPaymentMethod)}: missing '{_typePropertyName}' property"); + } + + var type = typeProperty.GetString(); + + return type switch + { + "bankAccount" => element.Deserialize(options)!, + "card" => element.Deserialize(options)!, + "payPal" => element.Deserialize(options)!, + _ => throw new JsonException($"Failed to deserialize {nameof(MaskedPaymentMethod)}: invalid '{_typePropertyName}' value - '{type}'") + }; + } + + public override void Write(Utf8JsonWriter writer, MaskedPaymentMethod value, JsonSerializerOptions options) + => value.Switch( + bankAccount => JsonSerializer.Serialize(writer, bankAccount, options), + card => JsonSerializer.Serialize(writer, card, options), + payPal => JsonSerializer.Serialize(writer, payPal, options)); +} diff --git a/src/Core/Billing/Payment/Models/ProductUsageType.cs b/src/Core/Billing/Payment/Models/ProductUsageType.cs new file mode 100644 index 0000000000..2ecd1233c6 --- /dev/null +++ b/src/Core/Billing/Payment/Models/ProductUsageType.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Payment.Models; + +public enum ProductUsageType +{ + Personal, + Business +} diff --git a/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs b/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs new file mode 100644 index 0000000000..c198ec8230 --- /dev/null +++ b/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs @@ -0,0 +1,22 @@ +namespace Bit.Core.Billing.Payment.Models; + +public enum TokenizablePaymentMethodType +{ + BankAccount, + Card, + PayPal +} + +public static class TokenizablePaymentMethodTypeExtensions +{ + public static TokenizablePaymentMethodType From(string type) + { + return type switch + { + "bankAccount" => TokenizablePaymentMethodType.BankAccount, + "card" => TokenizablePaymentMethodType.Card, + "payPal" => TokenizablePaymentMethodType.PayPal, + _ => throw new InvalidOperationException($"Invalid value for {nameof(TokenizedPaymentMethod)}.{nameof(TokenizedPaymentMethod.Type)}") + }; + } +} diff --git a/src/Core/Billing/Payment/Models/TokenizedPaymentMethod.cs b/src/Core/Billing/Payment/Models/TokenizedPaymentMethod.cs new file mode 100644 index 0000000000..9af7c9888a --- /dev/null +++ b/src/Core/Billing/Payment/Models/TokenizedPaymentMethod.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Payment.Models; + +public record TokenizedPaymentMethod +{ + public required TokenizablePaymentMethodType Type { get; set; } + public required string Token { get; set; } +} diff --git a/src/Core/Billing/Payment/Queries/GetBillingAddressQuery.cs b/src/Core/Billing/Payment/Queries/GetBillingAddressQuery.cs new file mode 100644 index 0000000000..e49c2cc993 --- /dev/null +++ b/src/Core/Billing/Payment/Queries/GetBillingAddressQuery.cs @@ -0,0 +1,40 @@ +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Stripe; + +namespace Bit.Core.Billing.Payment.Queries; + +public interface IGetBillingAddressQuery +{ + Task Run(ISubscriber subscriber); +} + +public class GetBillingAddressQuery( + ISubscriberService subscriberService) : IGetBillingAddressQuery +{ + public async Task Run(ISubscriber subscriber) + { + var productUsageType = subscriber.GetProductUsageType(); + + var options = productUsageType switch + { + ProductUsageType.Business => new CustomerGetOptions { Expand = ["tax_ids"] }, + _ => new CustomerGetOptions() + }; + + var customer = await subscriberService.GetCustomer(subscriber, options); + + if (customer is not { Address: { Country: not null, PostalCode: not null } }) + { + return null; + } + + var taxId = productUsageType == ProductUsageType.Business ? customer.TaxIds?.FirstOrDefault() : null; + + return taxId != null + ? BillingAddress.From(customer.Address, taxId) + : BillingAddress.From(customer.Address); + } +} diff --git a/src/Core/Billing/Payment/Queries/GetCreditQuery.cs b/src/Core/Billing/Payment/Queries/GetCreditQuery.cs new file mode 100644 index 0000000000..81d560269b --- /dev/null +++ b/src/Core/Billing/Payment/Queries/GetCreditQuery.cs @@ -0,0 +1,25 @@ +using Bit.Core.Billing.Services; +using Bit.Core.Entities; + +namespace Bit.Core.Billing.Payment.Queries; + +public interface IGetCreditQuery +{ + Task Run(ISubscriber subscriber); +} + +public class GetCreditQuery( + ISubscriberService subscriberService) : IGetCreditQuery +{ + public async Task Run(ISubscriber subscriber) + { + var customer = await subscriberService.GetCustomer(subscriber); + + if (customer == null) + { + return null; + } + + return Convert.ToDecimal(customer.Balance) * -1 / 100; + } +} diff --git a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs new file mode 100644 index 0000000000..9f9618571e --- /dev/null +++ b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs @@ -0,0 +1,90 @@ +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Services; +using Braintree; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Payment.Queries; + +public interface IGetPaymentMethodQuery +{ + Task Run(ISubscriber subscriber); +} + +public class GetPaymentMethodQuery( + IBraintreeGateway braintreeGateway, + ILogger logger, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService) : IGetPaymentMethodQuery +{ + public async Task Run(ISubscriber subscriber) + { + var customer = await subscriberService.GetCustomer(subscriber, + new CustomerGetOptions { Expand = ["default_source", "invoice_settings.default_payment_method"] }); + + if (customer == null) + { + return null; + } + + // First check for PayPal + if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) + { + var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId); + + if (braintreeCustomer.DefaultPaymentMethod is PayPalAccount payPalAccount) + { + return new MaskedPayPalAccount { Email = payPalAccount.Email }; + } + + logger.LogWarning("Subscriber ({SubscriberID}) has a linked Braintree customer ({BraintreeCustomerId}) with no PayPal account.", subscriber.Id, braintreeCustomerId); + + return null; + } + + // Then check for a bank account pending verification + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id); + + if (!string.IsNullOrEmpty(setupIntentId)) + { + var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + { + Expand = ["payment_method"] + }); + + if (setupIntent.IsUnverifiedBankAccount()) + { + return MaskedPaymentMethod.From(setupIntent); + } + } + + // Then check the default payment method + var paymentMethod = customer.InvoiceSettings.DefaultPaymentMethod != null + ? customer.InvoiceSettings.DefaultPaymentMethod.Type switch + { + "card" => MaskedPaymentMethod.From(customer.InvoiceSettings.DefaultPaymentMethod.Card), + "us_bank_account" => MaskedPaymentMethod.From(customer.InvoiceSettings.DefaultPaymentMethod.UsBankAccount), + _ => null + } + : null; + + if (paymentMethod != null) + { + return paymentMethod; + } + + return customer.DefaultSource switch + { + Card card => MaskedPaymentMethod.From(card), + BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount), + Source { Card: not null } source => MaskedPaymentMethod.From(source.Card), + _ => null + }; + } +} diff --git a/src/Core/Billing/Payment/Registrations.cs b/src/Core/Billing/Payment/Registrations.cs new file mode 100644 index 0000000000..478673d2fc --- /dev/null +++ b/src/Core/Billing/Payment/Registrations.cs @@ -0,0 +1,23 @@ +using Bit.Core.Billing.Payment.Clients; +using Bit.Core.Billing.Payment.Commands; +using Bit.Core.Billing.Payment.Queries; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Billing.Payment; + +public static class Registrations +{ + public static void AddPaymentOperations(this IServiceCollection services) + { + // Commands + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Queries + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + } +} diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs new file mode 100644 index 0000000000..1227cdc034 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -0,0 +1,308 @@ +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Braintree; +using Microsoft.Extensions.Logging; +using OneOf.Types; +using Stripe; +using Customer = Stripe.Customer; +using Subscription = Stripe.Subscription; + +namespace Bit.Core.Billing.Premium.Commands; + +using static Utilities; + +/// +/// Creates a premium subscription for a cloud-hosted user with Stripe payment processing. +/// Handles customer creation, payment method setup, and subscription creation. +/// +public interface ICreatePremiumCloudHostedSubscriptionCommand +{ + /// + /// Creates a premium cloud-hosted subscription for the specified user. + /// + /// The user to create the premium subscription for. Must not already be a premium user. + /// The tokenized payment method containing the payment type and token for billing. + /// The billing address information required for tax calculation and customer creation. + /// Additional storage in GB beyond the base 1GB included with premium (must be >= 0). + /// A billing command result indicating success or failure with appropriate error details. + Task> Run( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress, + short additionalStorageGb); +} + +public class CreatePremiumCloudHostedSubscriptionCommand( + IBraintreeGateway braintreeGateway, + IGlobalSettings globalSettings, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService, + IUserService userService, + IPushNotificationService pushNotificationService, + ILogger logger) + : BaseBillingCommand(logger), ICreatePremiumCloudHostedSubscriptionCommand +{ + private static readonly List _expand = ["tax"]; + private readonly ILogger _logger = logger; + + public Task> Run( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress, + short additionalStorageGb) => HandleAsync(async () => + { + if (user.Premium) + { + return new BadRequest("Already a premium user."); + } + + if (additionalStorageGb < 0) + { + return new BadRequest("Additional storage must be greater than 0."); + } + + var customer = string.IsNullOrEmpty(user.GatewayCustomerId) + ? await CreateCustomerAsync(user, paymentMethod, billingAddress) + : await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); + + customer = await ReconcileBillingLocationAsync(customer, billingAddress); + + var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null); + + switch (paymentMethod) + { + case { Type: TokenizablePaymentMethodType.PayPal } + when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete: + case { Type: not TokenizablePaymentMethodType.PayPal } + when subscription.Status == StripeConstants.SubscriptionStatus.Active: + { + user.Premium = true; + user.PremiumExpirationDate = subscription.CurrentPeriodEnd; + break; + } + } + + user.Gateway = GatewayType.Stripe; + user.GatewayCustomerId = customer.Id; + user.GatewaySubscriptionId = subscription.Id; + user.MaxStorageGb = (short)(1 + additionalStorageGb); + user.LicenseKey = CoreHelpers.SecureRandomString(20); + user.RevisionDate = DateTime.UtcNow; + + await userService.SaveUserAsync(user); + await pushNotificationService.PushSyncVaultAsync(user.Id); + + return new None(); + }); + + private async Task CreateCustomerAsync(User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + var subscriberName = user.SubscriberName(); + var customerCreateOptions = new CustomerCreateOptions + { + Address = new AddressOptions + { + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + PostalCode = billingAddress.PostalCode, + State = billingAddress.State, + Country = billingAddress.Country + }, + Description = user.Name, + Email = user.Email, + Expand = _expand, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = + [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = user.SubscriberType(), + Value = subscriberName.Length <= 30 + ? subscriberName + : subscriberName[..30] + } + ] + }, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion, + [StripeConstants.MetadataKeys.UserId] = user.Id.ToString() + }, + Tax = new CustomerTaxOptions + { + ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately + } + }; + + var braintreeCustomerId = ""; + + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (paymentMethod.Type) + { + case TokenizablePaymentMethodType.BankAccount: + { + var setupIntent = + (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token })) + .FirstOrDefault(); + + if (setupIntent == null) + { + _logger.LogError("Cannot create customer for user ({UserID}) without a setup intent for their bank account", user.Id); + throw new BillingException(); + } + + await setupIntentCache.Set(user.Id, setupIntent.Id); + break; + } + case TokenizablePaymentMethodType.Card: + { + customerCreateOptions.PaymentMethod = paymentMethod.Token; + customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token; + break; + } + case TokenizablePaymentMethodType.PayPal: + { + braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.Token); + customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; + break; + } + default: + { + _logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.Type.ToString()); + throw new BillingException(); + } + } + + try + { + return await stripeAdapter.CustomerCreateAsync(customerCreateOptions); + } + catch + { + await Revert(); + throw; + } + + async Task Revert() + { + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (paymentMethod.Type) + { + case TokenizablePaymentMethodType.BankAccount: + { + await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); + break; + } + case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): + { + await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); + break; + } + } + } + } + + private async Task ReconcileBillingLocationAsync( + Customer customer, + BillingAddress billingAddress) + { + /* + * If the customer was previously set up with credit, which does not require a billing location, + * we need to update the customer on the fly before we start the subscription. + */ + if (customer is { Address: { Country: not null and not "", PostalCode: not null and not "" } }) + { + return customer; + } + + var options = new CustomerUpdateOptions + { + Address = new AddressOptions + { + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + PostalCode = billingAddress.PostalCode, + State = billingAddress.State, + Country = billingAddress.Country + }, + Expand = _expand, + Tax = new CustomerTaxOptions + { + ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately + } + }; + return await stripeAdapter.CustomerUpdateAsync(customer.Id, options); + } + + private async Task CreateSubscriptionAsync( + Guid userId, + Customer customer, + int? storage) + { + var subscriptionItemOptionsList = new List + { + new () + { + Price = StripeConstants.Prices.PremiumAnnually, + Quantity = 1 + } + }; + + if (storage is > 0) + { + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Price = StripeConstants.Prices.StoragePlanPersonal, + Quantity = storage + }); + } + + var usingPayPal = customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) ?? false; + + var subscriptionCreateOptions = new SubscriptionCreateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = true + }, + CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, + Customer = customer.Id, + Items = subscriptionItemOptionsList, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.UserId] = userId.ToString() + }, + PaymentBehavior = usingPayPal + ? StripeConstants.PaymentBehavior.DefaultIncomplete + : null, + OffSession = true + }; + + var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + + if (usingPayPal) + { + await stripeAdapter.InvoiceUpdateAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions + { + AutoAdvance = false + }); + } + + return subscription; + } +} diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommand.cs new file mode 100644 index 0000000000..7546149ab6 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommand.cs @@ -0,0 +1,67 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using OneOf.Types; + +namespace Bit.Core.Billing.Premium.Commands; + +/// +/// Creates a premium subscription for a self-hosted user. +/// Validates the license and applies premium benefits including storage limits based on the license terms. +/// +public interface ICreatePremiumSelfHostedSubscriptionCommand +{ + /// + /// Creates a premium self-hosted subscription for the specified user using the provided license. + /// + /// The user to create the premium subscription for. Must not already be a premium user. + /// The user license containing the premium subscription details and verification data. Must be valid and usable by the specified user. + /// A billing command result indicating success or failure with appropriate error details. + Task> Run(User user, UserLicense license); +} + +public class CreatePremiumSelfHostedSubscriptionCommand( + ILicensingService licensingService, + IUserService userService, + IPushNotificationService pushNotificationService, + ILogger logger) + : BaseBillingCommand(logger), ICreatePremiumSelfHostedSubscriptionCommand +{ + public Task> Run( + User user, + UserLicense license) => HandleAsync(async () => + { + if (user.Premium) + { + return new BadRequest("Already a premium user."); + } + + if (!licensingService.VerifyLicense(license)) + { + return new BadRequest("Invalid license."); + } + + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + if (!license.CanUse(user, claimsPrincipal, out var exceptionMessage)) + { + return new BadRequest(exceptionMessage); + } + + await licensingService.WriteUserLicenseAsync(user, license); + + user.Premium = true; + user.RevisionDate = DateTime.UtcNow; + user.MaxStorageGb = Core.Constants.SelfHostedMaxStorageGb; + user.LicenseKey = license.LicenseKey; + user.PremiumExpirationDate = license.Expires; + + await userService.SaveUserAsync(user); + await pushNotificationService.PushSyncVaultAsync(user.Id); + + return new None(); + }); +} diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs new file mode 100644 index 0000000000..a0b4fcabc2 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs @@ -0,0 +1,65 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Premium.Commands; + +using static StripeConstants; + +public interface IPreviewPremiumTaxCommand +{ + Task> Run( + int additionalStorage, + BillingAddress billingAddress); +} + +public class PreviewPremiumTaxCommand( + ILogger logger, + IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IPreviewPremiumTaxCommand +{ + public Task> Run( + int additionalStorage, + BillingAddress billingAddress) + => HandleAsync<(decimal, decimal)>(async () => + { + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + } + }, + Currency = "usd", + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = + [ + new InvoiceSubscriptionDetailsItemOptions { Price = Prices.PremiumAnnually, Quantity = 1 } + ] + } + }; + + if (additionalStorage > 0) + { + options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = Prices.StoragePlanPersonal, + Quantity = additionalStorage + }); + } + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + }); + + private static (decimal, decimal) GetAmounts(Invoice invoice) => ( + Convert.ToDecimal(invoice.Tax) / 100, + Convert.ToDecimal(invoice.Total) / 100); +} diff --git a/src/Core/Billing/Pricing/JSON/FreeOrScalableDTOJsonConverter.cs b/src/Core/Billing/Pricing/JSON/FreeOrScalableDTOJsonConverter.cs deleted file mode 100644 index 37a8a4234d..0000000000 --- a/src/Core/Billing/Pricing/JSON/FreeOrScalableDTOJsonConverter.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Text.Json; -using Bit.Core.Billing.Pricing.Models; - -namespace Bit.Core.Billing.Pricing.JSON; - -#nullable enable - -public class FreeOrScalableDTOJsonConverter : TypeReadingJsonConverter -{ - public override FreeOrScalableDTO? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var type = ReadType(reader); - - return type switch - { - "free" => JsonSerializer.Deserialize(ref reader, options) switch - { - null => null, - var free => new FreeOrScalableDTO(free) - }, - "scalable" => JsonSerializer.Deserialize(ref reader, options) switch - { - null => null, - var scalable => new FreeOrScalableDTO(scalable) - }, - _ => null - }; - } - - public override void Write(Utf8JsonWriter writer, FreeOrScalableDTO value, JsonSerializerOptions options) - => value.Switch( - free => JsonSerializer.Serialize(writer, free, options), - scalable => JsonSerializer.Serialize(writer, scalable, options) - ); -} diff --git a/src/Core/Billing/Pricing/JSON/PurchasableDTOJsonConverter.cs b/src/Core/Billing/Pricing/JSON/PurchasableDTOJsonConverter.cs deleted file mode 100644 index f7ae9dc472..0000000000 --- a/src/Core/Billing/Pricing/JSON/PurchasableDTOJsonConverter.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json; -using Bit.Core.Billing.Pricing.Models; - -namespace Bit.Core.Billing.Pricing.JSON; - -#nullable enable -internal class PurchasableDTOJsonConverter : TypeReadingJsonConverter -{ - public override PurchasableDTO? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var type = ReadType(reader); - - return type switch - { - "free" => JsonSerializer.Deserialize(ref reader, options) switch - { - null => null, - var free => new PurchasableDTO(free) - }, - "packaged" => JsonSerializer.Deserialize(ref reader, options) switch - { - null => null, - var packaged => new PurchasableDTO(packaged) - }, - "scalable" => JsonSerializer.Deserialize(ref reader, options) switch - { - null => null, - var scalable => new PurchasableDTO(scalable) - }, - _ => null - }; - } - - public override void Write(Utf8JsonWriter writer, PurchasableDTO value, JsonSerializerOptions options) - => value.Switch( - free => JsonSerializer.Serialize(writer, free, options), - packaged => JsonSerializer.Serialize(writer, packaged, options), - scalable => JsonSerializer.Serialize(writer, scalable, options) - ); -} diff --git a/src/Core/Billing/Pricing/JSON/TypeReadingJsonConverter.cs b/src/Core/Billing/Pricing/JSON/TypeReadingJsonConverter.cs deleted file mode 100644 index ef8d33304e..0000000000 --- a/src/Core/Billing/Pricing/JSON/TypeReadingJsonConverter.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Bit.Core.Billing.Pricing.Models; - -namespace Bit.Core.Billing.Pricing.JSON; - -#nullable enable - -public abstract class TypeReadingJsonConverter : JsonConverter -{ - protected virtual string TypePropertyName => nameof(ScalableDTO.Type).ToLower(); - - protected string? ReadType(Utf8JsonReader reader) - { - while (reader.Read()) - { - if (reader.TokenType != JsonTokenType.PropertyName || reader.GetString()?.ToLower() != TypePropertyName) - { - continue; - } - - reader.Read(); - return reader.GetString(); - } - - return null; - } -} diff --git a/src/Core/Billing/Pricing/Models/Feature.cs b/src/Core/Billing/Pricing/Models/Feature.cs new file mode 100644 index 0000000000..ea9da5217d --- /dev/null +++ b/src/Core/Billing/Pricing/Models/Feature.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Pricing.Models; + +public class Feature +{ + public required string Name { get; set; } + public required string LookupKey { get; set; } +} diff --git a/src/Core/Billing/Pricing/Models/FeatureDTO.cs b/src/Core/Billing/Pricing/Models/FeatureDTO.cs deleted file mode 100644 index a96ac019e3..0000000000 --- a/src/Core/Billing/Pricing/Models/FeatureDTO.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Bit.Core.Billing.Pricing.Models; - -#nullable enable - -public class FeatureDTO -{ - public string Name { get; set; } = null!; - public string LookupKey { get; set; } = null!; -} diff --git a/src/Core/Billing/Pricing/Models/Plan.cs b/src/Core/Billing/Pricing/Models/Plan.cs new file mode 100644 index 0000000000..5b4296474b --- /dev/null +++ b/src/Core/Billing/Pricing/Models/Plan.cs @@ -0,0 +1,25 @@ +namespace Bit.Core.Billing.Pricing.Models; + +public class Plan +{ + public required string LookupKey { get; set; } + public required string Name { get; set; } + public required string Tier { get; set; } + public string? Cadence { get; set; } + public int? LegacyYear { get; set; } + public bool Available { get; set; } + public required Feature[] Features { get; set; } + public required Purchasable Seats { get; set; } + public Scalable? ManagedSeats { get; set; } + public Scalable? Storage { get; set; } + public SecretsManagerPurchasables? SecretsManager { get; set; } + public int? TrialPeriodDays { get; set; } + public required string[] CanUpgradeTo { get; set; } + public required Dictionary AdditionalData { get; set; } +} + +public class SecretsManagerPurchasables +{ + public required FreeOrScalable Seats { get; set; } + public required FreeOrScalable ServiceAccounts { get; set; } +} diff --git a/src/Core/Billing/Pricing/Models/PlanDTO.cs b/src/Core/Billing/Pricing/Models/PlanDTO.cs deleted file mode 100644 index 4ae82b3efe..0000000000 --- a/src/Core/Billing/Pricing/Models/PlanDTO.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Bit.Core.Billing.Pricing.Models; - -#nullable enable - -public class PlanDTO -{ - public string LookupKey { get; set; } = null!; - public string Name { get; set; } = null!; - public string Tier { get; set; } = null!; - public string? Cadence { get; set; } - public int? LegacyYear { get; set; } - public bool Available { get; set; } - public FeatureDTO[] Features { get; set; } = null!; - public PurchasableDTO Seats { get; set; } = null!; - public ScalableDTO? ManagedSeats { get; set; } - public ScalableDTO? Storage { get; set; } - public SecretsManagerPurchasablesDTO? SecretsManager { get; set; } - public int? TrialPeriodDays { get; set; } - public string[] CanUpgradeTo { get; set; } = null!; - public Dictionary AdditionalData { get; set; } = null!; -} - -public class SecretsManagerPurchasablesDTO -{ - public FreeOrScalableDTO Seats { get; set; } = null!; - public FreeOrScalableDTO ServiceAccounts { get; set; } = null!; -} diff --git a/src/Core/Billing/Pricing/Models/Purchasable.cs b/src/Core/Billing/Pricing/Models/Purchasable.cs new file mode 100644 index 0000000000..7cb4ee00c1 --- /dev/null +++ b/src/Core/Billing/Pricing/Models/Purchasable.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OneOf; + +namespace Bit.Core.Billing.Pricing.Models; + +[JsonConverter(typeof(PurchasableJsonConverter))] +public class Purchasable(OneOf input) : OneOfBase(input) +{ + public static implicit operator Purchasable(Free free) => new(free); + public static implicit operator Purchasable(Packaged packaged) => new(packaged); + public static implicit operator Purchasable(Scalable scalable) => new(scalable); + + public T? FromFree(Func select, Func? fallback = null) => + IsT0 ? select(AsT0) : fallback != null ? fallback(this) : default; + + public T? FromPackaged(Func select, Func? fallback = null) => + IsT1 ? select(AsT1) : fallback != null ? fallback(this) : default; + + public T? FromScalable(Func select, Func? fallback = null) => + IsT2 ? select(AsT2) : fallback != null ? fallback(this) : default; + + public bool IsFree => IsT0; + public bool IsPackaged => IsT1; + public bool IsScalable => IsT2; +} + +internal class PurchasableJsonConverter : JsonConverter +{ + private static readonly string _typePropertyName = nameof(Free.Type).ToLower(); + + public override Purchasable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var element = JsonElement.ParseValue(ref reader); + + if (!element.TryGetProperty(options.PropertyNamingPolicy?.ConvertName(_typePropertyName) ?? _typePropertyName, out var typeProperty)) + { + throw new JsonException( + $"Failed to deserialize {nameof(Purchasable)}: missing '{_typePropertyName}' property"); + } + + var type = typeProperty.GetString(); + + return type switch + { + "free" => element.Deserialize(options)!, + "packaged" => element.Deserialize(options)!, + "scalable" => element.Deserialize(options)!, + _ => throw new JsonException($"Failed to deserialize {nameof(Purchasable)}: invalid '{_typePropertyName}' value - '{type}'"), + }; + } + + public override void Write(Utf8JsonWriter writer, Purchasable value, JsonSerializerOptions options) + => value.Switch( + free => JsonSerializer.Serialize(writer, free, options), + packaged => JsonSerializer.Serialize(writer, packaged, options), + scalable => JsonSerializer.Serialize(writer, scalable, options) + ); +} + +[JsonConverter(typeof(FreeOrScalableJsonConverter))] +public class FreeOrScalable(OneOf input) : OneOfBase(input) +{ + public static implicit operator FreeOrScalable(Free free) => new(free); + public static implicit operator FreeOrScalable(Scalable scalable) => new(scalable); + + public T? FromFree(Func select, Func? fallback = null) => + IsT0 ? select(AsT0) : fallback != null ? fallback(this) : default; + + public T? FromScalable(Func select, Func? fallback = null) => + IsT1 ? select(AsT1) : fallback != null ? fallback(this) : default; + + public bool IsFree => IsT0; + public bool IsScalable => IsT1; +} + +public class FreeOrScalableJsonConverter : JsonConverter +{ + private static readonly string _typePropertyName = nameof(Free.Type).ToLower(); + + public override FreeOrScalable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var element = JsonElement.ParseValue(ref reader); + + if (!element.TryGetProperty(options.PropertyNamingPolicy?.ConvertName(_typePropertyName) ?? _typePropertyName, out var typeProperty)) + { + throw new JsonException( + $"Failed to deserialize {nameof(FreeOrScalable)}: missing '{_typePropertyName}' property"); + } + + var type = typeProperty.GetString(); + + return type switch + { + "free" => element.Deserialize(options)!, + "scalable" => element.Deserialize(options)!, + _ => throw new JsonException($"Failed to deserialize {nameof(FreeOrScalable)}: invalid '{_typePropertyName}' value - '{type}'"), + }; + } + + public override void Write(Utf8JsonWriter writer, FreeOrScalable value, JsonSerializerOptions options) + => value.Switch( + free => JsonSerializer.Serialize(writer, free, options), + scalable => JsonSerializer.Serialize(writer, scalable, options) + ); +} + +public class Free +{ + public int Quantity { get; set; } + public string Type => "free"; +} + +public class Packaged +{ + public int Quantity { get; set; } + public string StripePriceId { get; set; } = null!; + public decimal Price { get; set; } + public AdditionalSeats? Additional { get; set; } + public string Type => "packaged"; + + public class AdditionalSeats + { + public string StripePriceId { get; set; } = null!; + public decimal Price { get; set; } + } +} + +public class Scalable +{ + public int Provided { get; set; } + public string StripePriceId { get; set; } = null!; + public decimal Price { get; set; } + public string Type => "scalable"; +} diff --git a/src/Core/Billing/Pricing/Models/PurchasableDTO.cs b/src/Core/Billing/Pricing/Models/PurchasableDTO.cs deleted file mode 100644 index 8ba1c7b731..0000000000 --- a/src/Core/Billing/Pricing/Models/PurchasableDTO.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Text.Json.Serialization; -using Bit.Core.Billing.Pricing.JSON; -using OneOf; - -namespace Bit.Core.Billing.Pricing.Models; - -#nullable enable - -[JsonConverter(typeof(PurchasableDTOJsonConverter))] -public class PurchasableDTO(OneOf input) : OneOfBase(input) -{ - public static implicit operator PurchasableDTO(FreeDTO free) => new(free); - public static implicit operator PurchasableDTO(PackagedDTO packaged) => new(packaged); - public static implicit operator PurchasableDTO(ScalableDTO scalable) => new(scalable); - - public T? FromFree(Func select, Func? fallback = null) => - IsT0 ? select(AsT0) : fallback != null ? fallback(this) : default; - - public T? FromPackaged(Func select, Func? fallback = null) => - IsT1 ? select(AsT1) : fallback != null ? fallback(this) : default; - - public T? FromScalable(Func select, Func? fallback = null) => - IsT2 ? select(AsT2) : fallback != null ? fallback(this) : default; - - public bool IsFree => IsT0; - public bool IsPackaged => IsT1; - public bool IsScalable => IsT2; -} - -[JsonConverter(typeof(FreeOrScalableDTOJsonConverter))] -public class FreeOrScalableDTO(OneOf input) : OneOfBase(input) -{ - public static implicit operator FreeOrScalableDTO(FreeDTO freeDTO) => new(freeDTO); - public static implicit operator FreeOrScalableDTO(ScalableDTO scalableDTO) => new(scalableDTO); - - public T? FromFree(Func select, Func? fallback = null) => - IsT0 ? select(AsT0) : fallback != null ? fallback(this) : default; - - public T? FromScalable(Func select, Func? fallback = null) => - IsT1 ? select(AsT1) : fallback != null ? fallback(this) : default; - - public bool IsFree => IsT0; - public bool IsScalable => IsT1; -} - -public class FreeDTO -{ - public int Quantity { get; set; } - public string Type => "free"; -} - -public class PackagedDTO -{ - public int Quantity { get; set; } - public string StripePriceId { get; set; } = null!; - public decimal Price { get; set; } - public AdditionalSeats? Additional { get; set; } - public string Type => "packaged"; - - public class AdditionalSeats - { - public string StripePriceId { get; set; } = null!; - public decimal Price { get; set; } - } -} - -public class ScalableDTO -{ - public int Provided { get; set; } - public string StripePriceId { get; set; } = null!; - public decimal Price { get; set; } - public string Type => "scalable"; -} diff --git a/src/Core/Billing/Pricing/PlanAdapter.cs b/src/Core/Billing/Pricing/PlanAdapter.cs index 45a48c3f80..560987b891 100644 --- a/src/Core/Billing/Pricing/PlanAdapter.cs +++ b/src/Core/Billing/Pricing/PlanAdapter.cs @@ -1,14 +1,12 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing.Models; -using Bit.Core.Models.StaticStore; - -#nullable enable +using Plan = Bit.Core.Billing.Pricing.Models.Plan; namespace Bit.Core.Billing.Pricing; -public record PlanAdapter : Plan +public record PlanAdapter : Core.Models.StaticStore.Plan { - public PlanAdapter(PlanDTO plan) + public PlanAdapter(Plan plan) { Type = ToPlanType(plan.LookupKey); ProductTier = ToProductTierType(Type); @@ -88,7 +86,7 @@ public record PlanAdapter : Plan _ => throw new BillingException() // TODO: Flesh out }; - private static PasswordManagerPlanFeatures ToPasswordManagerPlanFeatures(PlanDTO plan) + private static PasswordManagerPlanFeatures ToPasswordManagerPlanFeatures(Plan plan) { var stripePlanId = GetStripePlanId(plan.Seats); var stripeSeatPlanId = GetStripeSeatPlanId(plan.Seats); @@ -128,7 +126,7 @@ public record PlanAdapter : Plan }; } - private static SecretsManagerPlanFeatures ToSecretsManagerPlanFeatures(PlanDTO plan) + private static SecretsManagerPlanFeatures ToSecretsManagerPlanFeatures(Plan plan) { var seats = plan.SecretsManager!.Seats; var serviceAccounts = plan.SecretsManager.ServiceAccounts; @@ -165,62 +163,62 @@ public record PlanAdapter : Plan }; } - private static decimal? GetAdditionalPricePerServiceAccount(FreeOrScalableDTO freeOrScalable) + private static decimal? GetAdditionalPricePerServiceAccount(FreeOrScalable freeOrScalable) => freeOrScalable.FromScalable(x => x.Price); - private static decimal GetBasePrice(PurchasableDTO purchasable) + private static decimal GetBasePrice(Purchasable purchasable) => purchasable.FromPackaged(x => x.Price); - private static int GetBaseSeats(FreeOrScalableDTO freeOrScalable) + private static int GetBaseSeats(FreeOrScalable freeOrScalable) => freeOrScalable.Match( free => free.Quantity, scalable => scalable.Provided); - private static int GetBaseSeats(PurchasableDTO purchasable) + private static int GetBaseSeats(Purchasable purchasable) => purchasable.Match( free => free.Quantity, packaged => packaged.Quantity, scalable => scalable.Provided); - private static short GetBaseServiceAccount(FreeOrScalableDTO freeOrScalable) + private static short GetBaseServiceAccount(FreeOrScalable freeOrScalable) => freeOrScalable.Match( free => (short)free.Quantity, scalable => (short)scalable.Provided); - private static short? GetMaxSeats(PurchasableDTO purchasable) + private static short? GetMaxSeats(Purchasable purchasable) => purchasable.Match( free => (short)free.Quantity, packaged => (short)packaged.Quantity, _ => null); - private static short? GetMaxSeats(FreeOrScalableDTO freeOrScalable) + private static short? GetMaxSeats(FreeOrScalable freeOrScalable) => freeOrScalable.FromFree(x => (short)x.Quantity); - private static short? GetMaxServiceAccounts(FreeOrScalableDTO freeOrScalable) + private static short? GetMaxServiceAccounts(FreeOrScalable freeOrScalable) => freeOrScalable.FromFree(x => (short)x.Quantity); - private static decimal GetSeatPrice(PurchasableDTO purchasable) + private static decimal GetSeatPrice(Purchasable purchasable) => purchasable.Match( _ => 0, packaged => packaged.Additional?.Price ?? 0, scalable => scalable.Price); - private static decimal GetSeatPrice(FreeOrScalableDTO freeOrScalable) + private static decimal GetSeatPrice(FreeOrScalable freeOrScalable) => freeOrScalable.FromScalable(x => x.Price); - private static string? GetStripePlanId(PurchasableDTO purchasable) + private static string? GetStripePlanId(Purchasable purchasable) => purchasable.FromPackaged(x => x.StripePriceId); - private static string? GetStripeSeatPlanId(PurchasableDTO purchasable) + private static string? GetStripeSeatPlanId(Purchasable purchasable) => purchasable.Match( _ => null, packaged => packaged.Additional?.StripePriceId, scalable => scalable.StripePriceId); - private static string? GetStripeSeatPlanId(FreeOrScalableDTO freeOrScalable) + private static string? GetStripeSeatPlanId(FreeOrScalable freeOrScalable) => freeOrScalable.FromScalable(x => x.StripePriceId); - private static string? GetStripeServiceAccountPlanId(FreeOrScalableDTO freeOrScalable) + private static string? GetStripeServiceAccountPlanId(FreeOrScalable freeOrScalable) => freeOrScalable.FromScalable(x => x.StripePriceId); #endregion diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs index 14caa54eb4..a3db8ce07f 100644 --- a/src/Core/Billing/Pricing/PricingClient.cs +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -1,7 +1,6 @@ using System.Net; using System.Net.Http.Json; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing.Models; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Settings; @@ -45,7 +44,7 @@ public class PricingClient( if (response.IsSuccessStatusCode) { - var plan = await response.Content.ReadFromJsonAsync(); + var plan = await response.Content.ReadFromJsonAsync(); if (plan == null) { throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); @@ -93,7 +92,7 @@ public class PricingClient( if (response.IsSuccessStatusCode) { - var plans = await response.Content.ReadFromJsonAsync>(); + var plans = await response.Content.ReadFromJsonAsync>(); if (plans == null) { throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); diff --git a/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs b/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs index ae0c28de86..65fd7726f8 100644 --- a/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs +++ b/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Billing.Providers.Migration.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Billing.Providers.Migration.Models; public enum ClientMigrationProgress { diff --git a/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs index 6f3c3be11d..78a2631999 100644 --- a/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs +++ b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Providers.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Providers.Entities; namespace Bit.Core.Billing.Providers.Migration.Models; diff --git a/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs index f4708d4cbd..ba39feab2d 100644 --- a/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs +++ b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Billing.Providers.Migration.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Billing.Providers.Migration.Models; public enum ProviderMigrationProgress { diff --git a/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs index ea7d118cfa..1f38b0d111 100644 --- a/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Providers.Migration.Models; diff --git a/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs index 3b874579e5..3de49838af 100644 --- a/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; diff --git a/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs index 3a0b579dcf..e155b427f1 100644 --- a/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs @@ -1,14 +1,19 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Migration.Models; using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; +using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; @@ -250,7 +255,10 @@ public class ProviderMigrator( var taxInfo = await paymentService.GetTaxInfoAsync(sampleOrganization); - var customer = await providerBillingService.SetupCustomer(provider, taxInfo); + // Create dummy payment source for legacy migration - this migrator is deprecated and will be removed + var dummyPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "migration_dummy_token"); + + var customer = await providerBillingService.SetupCustomer(provider, null, null); await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { diff --git a/src/Core/Billing/Providers/Models/ProviderWarnings.cs b/src/Core/Billing/Providers/Models/ProviderWarnings.cs new file mode 100644 index 0000000000..dd9d9be41c --- /dev/null +++ b/src/Core/Billing/Providers/Models/ProviderWarnings.cs @@ -0,0 +1,18 @@ +namespace Bit.Core.Billing.Providers.Models; + +public class ProviderWarnings +{ + public SuspensionWarning? Suspension { get; set; } + public TaxIdWarning? TaxId { get; set; } + + public record SuspensionWarning + { + public required string Resolution { get; set; } + public DateTime? SubscriptionCancelsAt { get; set; } + } + + public record TaxIdWarning + { + public required string Type { get; set; } + } +} diff --git a/src/Core/Billing/Providers/Queries/IGetProviderWarningsQuery.cs b/src/Core/Billing/Providers/Queries/IGetProviderWarningsQuery.cs new file mode 100644 index 0000000000..ed868a8475 --- /dev/null +++ b/src/Core/Billing/Providers/Queries/IGetProviderWarningsQuery.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Providers.Models; + +namespace Bit.Core.Billing.Providers.Queries; + +public interface IGetProviderWarningsQuery +{ + Task Run(Provider provider); +} diff --git a/src/Core/Billing/Providers/Services/IProviderBillingService.cs b/src/Core/Billing/Providers/Services/IProviderBillingService.cs index b634f1a81c..57d68db038 100644 --- a/src/Core/Billing/Providers/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Providers/Services/IProviderBillingService.cs @@ -1,11 +1,14 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Tax.Models; -using Bit.Core.Models.Business; using Stripe; namespace Bit.Core.Billing.Providers.Services; @@ -76,16 +79,16 @@ public interface IProviderBillingService int seatAdjustment); /// - /// For use during the provider setup process, this method creates a Stripe for the specified utilizing the provided . + /// For use during the provider setup process, this method creates a Stripe for the specified utilizing the provided and . /// /// The to create a Stripe customer for. - /// The to use for calculating the customer's automatic tax. - /// The (ex. Credit Card) to attach to the customer. + /// The (e.g., Credit Card, Bank Account, or PayPal) to attach to the customer. + /// The containing the customer's billing information including address and tax ID details. /// The newly created for the . Task SetupCustomer( Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource = null); + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress); /// /// For use during the provider setup process, this method starts a Stripe for the given . diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs b/src/Core/Billing/Providers/Services/ProviderPriceAdapter.cs similarity index 95% rename from bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs rename to src/Core/Billing/Providers/Services/ProviderPriceAdapter.cs index 8c55d31f2c..1346afe914 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs +++ b/src/Core/Billing/Providers/Services/ProviderPriceAdapter.cs @@ -1,12 +1,10 @@ // ReSharper disable SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault -#nullable enable using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; -using Bit.Core.Billing; using Bit.Core.Billing.Enums; using Stripe; -namespace Bit.Commercial.Core.Billing.Providers.Services; +namespace Bit.Core.Billing.Providers.Services; public static class ProviderPriceAdapter { @@ -52,7 +50,7 @@ public static class ProviderPriceAdapter /// The plan type correlating to the desired Stripe price ID. /// A Stripe ID. /// Thrown when the provider's type is not or . - /// Thrown when the provided does not relate to a Stripe price ID. + /// Thrown when the provided does not relate to a Stripe price ID. public static string GetPriceId( Provider provider, Subscription subscription, @@ -104,7 +102,7 @@ public static class ProviderPriceAdapter /// The plan type correlating to the desired Stripe price ID. /// A Stripe ID. /// Thrown when the provider's type is not or . - /// Thrown when the provided does not relate to a Stripe price ID. + /// Thrown when the provided does not relate to a Stripe price ID. public static string GetActivePriceId( Provider provider, PlanType planType) diff --git a/src/Core/Services/ILicensingService.cs b/src/Core/Billing/Services/ILicensingService.cs similarity index 82% rename from src/Core/Services/ILicensingService.cs rename to src/Core/Billing/Services/ILicensingService.cs index 2115e43085..cd9847ea39 100644 --- a/src/Core/Services/ILicensingService.cs +++ b/src/Core/Billing/Services/ILicensingService.cs @@ -2,10 +2,12 @@ using System.Security.Claims; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; -namespace Bit.Core.Services; +namespace Bit.Core.Billing.Services; public interface ILicensingService { @@ -24,4 +26,5 @@ public interface ILicensingService SubscriptionInfo subscriptionInfo); Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo); + Task WriteUserLicenseAsync(User user, UserLicense license); } diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index 6910948436..f88727f37b 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models; using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Enums; @@ -33,6 +36,9 @@ public interface ISubscriberService ISubscriber subscriber, string paymentMethodNonce); + Task CreateStripeCustomer( + ISubscriber subscriber); + /// /// Retrieves a Stripe using the 's property. /// @@ -151,4 +157,22 @@ public interface ISubscriberService Task VerifyBankAccount( ISubscriber subscriber, string descriptorCode); + + /// + /// Validates whether the 's exists in the gateway. + /// If the 's is or empty, returns . + /// + /// The subscriber whose gateway customer ID should be validated. + /// if the gateway customer ID is valid or empty; if the customer doesn't exist in the gateway. + /// Thrown when the is . + Task IsValidGatewayCustomerIdAsync(ISubscriber subscriber); + + /// + /// Validates whether the 's exists in the gateway. + /// If the 's is or empty, returns . + /// + /// The subscriber whose gateway subscription ID should be validated. + /// if the gateway subscription ID is valid or empty; if the subscription doesn't exist in the gateway. + /// Thrown when the is . + Task IsValidGatewaySubscriptionIdAsync(ISubscriber subscriber); } diff --git a/src/Core/Services/Implementations/LicensingService.cs b/src/Core/Billing/Services/Implementations/LicensingService.cs similarity index 92% rename from src/Core/Services/Implementations/LicensingService.cs rename to src/Core/Billing/Services/Implementations/LicensingService.cs index 2d91017ce2..6f0cdec8f5 100644 --- a/src/Core/Services/Implementations/LicensingService.cs +++ b/src/Core/Billing/Services/Implementations/LicensingService.cs @@ -1,4 +1,7 @@ -using System.IdentityModel.Tokens.Jwt; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using System.Text; @@ -6,19 +9,22 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Licenses.Models; using Bit.Core.Billing.Licenses.Services; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; -using IdentityModel; +using Duende.IdentityModel; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; -namespace Bit.Core.Services; +namespace Bit.Core.Billing.Services; public class LicensingService : ILicensingService { @@ -91,7 +97,7 @@ public class LicensingService : ILicensingService } var enabledOrgs = await _organizationRepository.GetManyByEnabledAsync(); - _logger.LogInformation(Constants.BypassFiltersEventId, null, + _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Validating licenses for {NumberOfOrganizations} organizations.", enabledOrgs.Count); var exceptions = new List(); @@ -140,7 +146,7 @@ public class LicensingService : ILicensingService private async Task DisableOrganizationAsync(Organization org, ILicense license, string reason) { - _logger.LogInformation(Constants.BypassFiltersEventId, null, + _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Organization {0} ({1}) has an invalid license and is being disabled. Reason: {2}", org.Id, org.DisplayName(), reason); org.Enabled = false; @@ -159,7 +165,7 @@ public class LicensingService : ILicensingService } var premiumUsers = await _userRepository.GetManyByPremiumAsync(true); - _logger.LogInformation(Constants.BypassFiltersEventId, null, + _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Validating premium for {0} users.", premiumUsers.Count); foreach (var user in premiumUsers) @@ -198,7 +204,7 @@ public class LicensingService : ILicensingService _userCheckCache.Add(user.Id, now); } - _logger.LogInformation(Constants.BypassFiltersEventId, null, + _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Validating premium license for user {0}({1}).", user.Id, user.Email); return await ProcessUserValidationAsync(user); } @@ -230,7 +236,7 @@ public class LicensingService : ILicensingService private async Task DisablePremiumAsync(User user, ILicense license, string reason) { - _logger.LogInformation(Constants.BypassFiltersEventId, null, + _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "User {0}({1}) has an invalid license and premium is being disabled. Reason: {2}", user.Id, user.Email, reason); @@ -383,4 +389,12 @@ public class LicensingService : ILicensingService var token = tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); } + + public async Task WriteUserLicenseAsync(User user, UserLicense license) + { + var dir = $"{_globalSettings.LicenseDirectory}/user"; + Directory.CreateDirectory(dir); + await using var fs = File.OpenWrite(Path.Combine(dir, $"{user.Id}.json")); + await JsonSerializer.SerializeAsync(fs, license, JsonHelpers.Indented); + } } diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 7496157aaa..9db18278b6 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Caches; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; @@ -280,7 +283,7 @@ public class PremiumUserBillingService( { case PaymentMethodType.BankAccount: { - await setupIntentCache.Remove(user.Id); + await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); break; } case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): @@ -301,7 +304,7 @@ public class PremiumUserBillingService( { new () { - Price = "premium-annually", + Price = StripeConstants.Prices.PremiumAnnually, Quantity = 1 } }; diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 796f700e9f..8e75bf3dca 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -1,5 +1,9 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; @@ -10,6 +14,7 @@ using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -24,14 +29,18 @@ using Subscription = Stripe.Subscription; namespace Bit.Core.Billing.Services.Implementations; +using static StripeConstants; + public class SubscriberService( IBraintreeGateway braintreeGateway, - IFeatureService featureService, IGlobalSettings globalSettings, ILogger logger, + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, - ITaxService taxService) : ISubscriberService + ITaxService taxService, + IUserRepository userRepository) : ISubscriberService { public async Task CancelSubscription( ISubscriber subscriber, @@ -143,6 +152,110 @@ public class SubscriberService( throw new BillingException(); } +#nullable enable + public async Task CreateStripeCustomer(ISubscriber subscriber) + { + if (!string.IsNullOrEmpty(subscriber.GatewayCustomerId)) + { + throw new ConflictException("Subscriber already has a linked Stripe Customer"); + } + + var options = subscriber switch + { + Organization organization => new CustomerCreateOptions + { + Description = organization.DisplayBusinessName(), + Email = organization.BillingEmail, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = + [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = organization.SubscriberType(), + Value = Max30Characters(organization.DisplayName()) + } + ] + }, + Metadata = new Dictionary + { + [MetadataKeys.OrganizationId] = organization.Id.ToString(), + [MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion + } + }, + Provider provider => new CustomerCreateOptions + { + Description = provider.DisplayBusinessName(), + Email = provider.BillingEmail, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = + [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = provider.SubscriberType(), + Value = Max30Characters(provider.DisplayName()) + } + ] + }, + Metadata = new Dictionary + { + [MetadataKeys.ProviderId] = provider.Id.ToString(), + [MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion + } + }, + User user => new CustomerCreateOptions + { + Description = user.Name, + Email = user.Email, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = + [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = user.SubscriberType(), + Value = Max30Characters(user.SubscriberName()) + } + ] + }, + Metadata = new Dictionary + { + [MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion, + [MetadataKeys.UserId] = user.Id.ToString() + } + }, + _ => throw new ArgumentOutOfRangeException(nameof(subscriber)) + }; + + var customer = await stripeAdapter.CustomerCreateAsync(options); + + switch (subscriber) + { + case Organization organization: + organization.Gateway = GatewayType.Stripe; + organization.GatewayCustomerId = customer.Id; + await organizationRepository.ReplaceAsync(organization); + break; + case Provider provider: + provider.Gateway = GatewayType.Stripe; + provider.GatewayCustomerId = customer.Id; + await providerRepository.ReplaceAsync(provider); + break; + case User user: + user.Gateway = GatewayType.Stripe; + user.GatewayCustomerId = customer.Id; + await userRepository.ReplaceAsync(user); + break; + } + + return customer; + + string? Max30Characters(string? input) + => input?.Length <= 30 ? input : input?[..30]; + } +#nullable disable + public async Task GetCustomer( ISubscriber subscriber, CustomerGetOptions customerGetOptions = null) @@ -232,7 +345,7 @@ public class SubscriberService( return PaymentMethod.Empty; } - var accountCredit = customer.Balance * -1 / 100; + var accountCredit = customer.Balance * -1 / 100M; var paymentMethod = await GetPaymentSourceAsync(subscriber.Id, customer); @@ -467,11 +580,6 @@ public class SubscriberService( PaymentMethod = token }); - var getExistingSetupIntentsForCustomer = stripeAdapter.SetupIntentList(new SetupIntentListOptions - { - Customer = subscriber.GatewayCustomerId - }); - // Find the setup intent for the incoming payment method token. var setupIntentsForUpdatedPaymentMethod = await getSetupIntentsForUpdatedPaymentMethod; @@ -484,24 +592,15 @@ public class SubscriberService( var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod.First(); - // Find the customer's existing setup intents that should be canceled. - var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer) - .Where(si => - si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); - // Store the incoming payment method's setup intent ID in the cache for the subscriber so it can be verified later. await setupIntentCache.Set(subscriber.Id, matchingSetupIntent.Id); - // Cancel the customer's other open setup intents. - var postProcessing = existingSetupIntentsForCustomer.Select(si => - stripeAdapter.SetupIntentCancel(si.Id, - new SetupIntentCancelOptions { CancellationReason = "abandoned" })).ToList(); - // Remove the customer's other attached Stripe payment methods. - postProcessing.Add(RemoveStripePaymentMethodsAsync(customer)); - - // Remove the customer's Braintree customer ID. - postProcessing.Add(RemoveBraintreeCustomerIdAsync(customer)); + var postProcessing = new List + { + RemoveStripePaymentMethodsAsync(customer), + RemoveBraintreeCustomerIdAsync(customer) + }; await Task.WhenAll(postProcessing); @@ -509,11 +608,6 @@ public class SubscriberService( } case PaymentMethodType.Card: { - var getExistingSetupIntentsForCustomer = stripeAdapter.SetupIntentList(new SetupIntentListOptions - { - Customer = subscriber.GatewayCustomerId - }); - // Remove the customer's other attached Stripe payment methods. await RemoveStripePaymentMethodsAsync(customer); @@ -521,16 +615,6 @@ public class SubscriberService( await stripeAdapter.PaymentMethodAttachAsync(token, new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); - // Find the customer's existing setup intents that should be canceled. - var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer) - .Where(si => - si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); - - // Cancel the customer's other open setup intents. - var postProcessing = existingSetupIntentsForCustomer.Select(si => - stripeAdapter.SetupIntentCancel(si.Id, - new SetupIntentCancelOptions { CancellationReason = "abandoned" })).ToList(); - var metadata = customer.Metadata; if (metadata.TryGetValue(BraintreeCustomerIdKey, out var value)) @@ -540,16 +624,14 @@ public class SubscriberService( } // Set the customer's default payment method in Stripe and remove their Braintree customer ID. - postProcessing.Add(stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions + await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { DefaultPaymentMethod = token }, Metadata = metadata - })); - - await Task.WhenAll(postProcessing); + }); break; } @@ -688,28 +770,25 @@ public class SubscriberService( _ => false }; - var setNonUSBusinessUseToReverseCharge = - featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); - - if (setNonUSBusinessUseToReverseCharge && isBusinessUseSubscriber) + if (isBusinessUseSubscriber) { switch (customer) { case { - Address.Country: not "US", - TaxExempt: not StripeConstants.TaxExempt.Reverse + Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, + TaxExempt: not TaxExempt.Reverse }: await stripeAdapter.CustomerUpdateAsync(customer.Id, - new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse }); break; case { - Address.Country: "US", - TaxExempt: StripeConstants.TaxExempt.Reverse + Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, + TaxExempt: TaxExempt.Reverse }: await stripeAdapter.CustomerUpdateAsync(customer.Id, - new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.None }); + new CustomerUpdateOptions { TaxExempt = TaxExempt.None }); break; } @@ -728,8 +807,8 @@ public class SubscriberService( { User => true, Organization organization => organization.PlanType.GetProductTier() == ProductTierType.Families || - customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false), - Provider => customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false), + customer.Address.Country == Core.Constants.CountryAbbreviations.UnitedStates || (customer.TaxIds?.Any() ?? false), + Provider => customer.Address.Country == Core.Constants.CountryAbbreviations.UnitedStates || (customer.TaxIds?.Any() ?? false), _ => false }; @@ -748,7 +827,7 @@ public class SubscriberService( ISubscriber subscriber, string descriptorCode) { - var setupIntentId = await setupIntentCache.Get(subscriber.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id); if (string.IsNullOrEmpty(setupIntentId)) { @@ -795,6 +874,44 @@ public class SubscriberService( } } + public async Task IsValidGatewayCustomerIdAsync(ISubscriber subscriber) + { + ArgumentNullException.ThrowIfNull(subscriber); + if (string.IsNullOrEmpty(subscriber.GatewayCustomerId)) + { + // subscribers are allowed to have no customer id as a business rule + return true; + } + try + { + await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId); + return true; + } + catch (StripeException e) when (e.StripeError.Code == "resource_missing") + { + return false; + } + } + + public async Task IsValidGatewaySubscriptionIdAsync(ISubscriber subscriber) + { + ArgumentNullException.ThrowIfNull(subscriber); + if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId)) + { + // subscribers are allowed to have no subscription id as a business rule + return true; + } + try + { + await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); + return true; + } + catch (StripeException e) when (e.StripeError.Code == "resource_missing") + { + return false; + } + } + #region Shared Utilities private async Task AddBraintreeCustomerIdAsync( @@ -838,7 +955,7 @@ public class SubscriberService( * attachedPaymentMethodDTO being null represents a case where we could be looking for the SetupIntent for an unverified "us_bank_account". * We store the ID of this SetupIntent in the cache when we originally update the payment method. */ - var setupIntentId = await setupIntentCache.Get(subscriberId); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriberId); if (string.IsNullOrEmpty(setupIntentId)) { diff --git a/src/Core/Services/NoopImplementations/NoopLicensingService.cs b/src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs similarity index 88% rename from src/Core/Services/NoopImplementations/NoopLicensingService.cs rename to src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs index b181e61138..b27e21a7c9 100644 --- a/src/Core/Services/NoopImplementations/NoopLicensingService.cs +++ b/src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs @@ -2,13 +2,15 @@ using System.Security.Claims; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; using Bit.Core.Settings; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; -namespace Bit.Core.Services; +namespace Bit.Core.Billing.Services; public class NoopLicensingService : ILicensingService { @@ -71,4 +73,9 @@ public class NoopLicensingService : ILicensingService { return Task.FromResult(null); } + + public Task WriteUserLicenseAsync(User user, UserLicense license) + { + return Task.CompletedTask; + } } diff --git a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs new file mode 100644 index 0000000000..351c75ace0 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs @@ -0,0 +1,92 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using OneOf.Types; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Commands; + +using static StripeConstants; + +public interface IRestartSubscriptionCommand +{ + Task> Run( + ISubscriber subscriber); +} + +public class RestartSubscriptionCommand( + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService, + IUserRepository userRepository) : IRestartSubscriptionCommand +{ + public async Task> Run( + ISubscriber subscriber) + { + var existingSubscription = await subscriberService.GetSubscription(subscriber); + + if (existingSubscription is not { Status: SubscriptionStatus.Canceled }) + { + return new BadRequest("Cannot restart a subscription that is not canceled."); + } + + var options = new SubscriptionCreateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, + CollectionMethod = CollectionMethod.ChargeAutomatically, + Customer = existingSubscription.CustomerId, + Items = existingSubscription.Items.Select(subscriptionItem => new SubscriptionItemOptions + { + Price = subscriptionItem.Price.Id, + Quantity = subscriptionItem.Quantity + }).ToList(), + Metadata = existingSubscription.Metadata, + OffSession = true, + TrialPeriodDays = 0 + }; + + var subscription = await stripeAdapter.SubscriptionCreateAsync(options); + await EnableAsync(subscriber, subscription); + return new None(); + } + + private async Task EnableAsync(ISubscriber subscriber, Subscription subscription) + { + switch (subscriber) + { + case Organization organization: + { + organization.GatewaySubscriptionId = subscription.Id; + organization.Enabled = true; + organization.ExpirationDate = subscription.CurrentPeriodEnd; + organization.RevisionDate = DateTime.UtcNow; + await organizationRepository.ReplaceAsync(organization); + break; + } + case Provider provider: + { + provider.GatewaySubscriptionId = subscription.Id; + provider.Enabled = true; + provider.RevisionDate = DateTime.UtcNow; + await providerRepository.ReplaceAsync(provider); + break; + } + case User user: + { + user.GatewaySubscriptionId = subscription.Id; + user.Premium = true; + user.PremiumExpirationDate = subscription.CurrentPeriodEnd; + user.RevisionDate = DateTime.UtcNow; + await userRepository.ReplaceAsync(user); + break; + } + } + } +} diff --git a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs deleted file mode 100644 index c777d0c0d1..0000000000 --- a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs +++ /dev/null @@ -1,156 +0,0 @@ -#nullable enable -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Models; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Services; -using Bit.Core.Services; -using Microsoft.Extensions.Logging; -using Stripe; - -namespace Bit.Core.Billing.Tax.Commands; - -public interface IPreviewTaxAmountCommand -{ - Task> Run(OrganizationTrialParameters parameters); -} - -public class PreviewTaxAmountCommand( - ILogger logger, - IPricingClient pricingClient, - IStripeAdapter stripeAdapter, - ITaxService taxService) : IPreviewTaxAmountCommand -{ - public async Task> Run(OrganizationTrialParameters parameters) - { - var (planType, productType, taxInformation) = parameters; - - var plan = await pricingClient.GetPlanOrThrow(planType); - - var options = new InvoiceCreatePreviewOptions - { - Currency = "usd", - CustomerDetails = new InvoiceCustomerDetailsOptions - { - Address = new AddressOptions - { - Country = taxInformation.Country, - PostalCode = taxInformation.PostalCode - } - }, - SubscriptionDetails = new InvoiceSubscriptionDetailsOptions - { - Items = [ - new InvoiceSubscriptionDetailsItemOptions - { - Price = plan.HasNonSeatBasedPasswordManagerPlan() ? plan.PasswordManager.StripePlanId : plan.PasswordManager.StripeSeatPlanId, - Quantity = 1 - } - ] - } - }; - - if (productType == ProductType.SecretsManager) - { - options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions - { - Price = plan.SecretsManager.StripeSeatPlanId, - Quantity = 1 - }); - - options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone; - } - - if (!string.IsNullOrEmpty(taxInformation.TaxId)) - { - var taxIdType = taxService.GetStripeTaxCode( - taxInformation.Country, - taxInformation.TaxId); - - if (string.IsNullOrEmpty(taxIdType)) - { - return BadRequest.UnknownTaxIdType; - } - - options.CustomerDetails.TaxIds = [ - new InvoiceCustomerDetailsTaxIdOptions - { - Type = taxIdType, - Value = taxInformation.TaxId - } - ]; - - if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) - { - options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions - { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{parameters.TaxInformation.TaxId}" - }); - } - } - - if (planType.GetProductTier() == ProductTierType.Families) - { - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - } - else - { - options.AutomaticTax = new InvoiceAutomaticTaxOptions - { - Enabled = options.CustomerDetails.Address.Country == "US" || - options.CustomerDetails.TaxIds is [_, ..] - }; - } - - try - { - var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); - return Convert.ToDecimal(invoice.Tax) / 100; - } - catch (StripeException stripeException) when (stripeException.StripeError.Code == - StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) - { - return BadRequest.TaxLocationInvalid; - } - catch (StripeException stripeException) when (stripeException.StripeError.Code == - StripeConstants.ErrorCodes.TaxIdInvalid) - { - return BadRequest.TaxIdNumberInvalid; - } - catch (StripeException stripeException) - { - logger.LogError(stripeException, "Stripe responded with an error during {Operation}. Code: {Code}", nameof(PreviewTaxAmountCommand), stripeException.StripeError.Code); - return new Unhandled(); - } - } -} - -#region Command Parameters - -public record OrganizationTrialParameters -{ - public required PlanType PlanType { get; set; } - public required ProductType ProductType { get; set; } - public required TaxInformationDTO TaxInformation { get; set; } - - public void Deconstruct( - out PlanType planType, - out ProductType productType, - out TaxInformationDTO taxInformation) - { - planType = PlanType; - productType = ProductType; - taxInformation = TaxInformation; - } - - public record TaxInformationDTO - { - public required string Country { get; set; } - public required string PostalCode { get; set; } - public string? TaxId { get; set; } - } -} - -#endregion diff --git a/src/Core/Billing/Tax/Models/TaxIdType.cs b/src/Core/Billing/Tax/Models/TaxIdType.cs index 6f8cfdde99..005b1eb6a6 100644 --- a/src/Core/Billing/Tax/Models/TaxIdType.cs +++ b/src/Core/Billing/Tax/Models/TaxIdType.cs @@ -1,4 +1,7 @@ -using System.Text.RegularExpressions; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.RegularExpressions; namespace Bit.Core.Billing.Tax.Models; diff --git a/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs b/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs index 340f07b56c..db5ba190bd 100644 --- a/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Billing.Tax.Requests; diff --git a/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs b/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs index bfb47e7b2c..f0bc368f07 100644 --- a/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Enums; using Bit.Core.Enums; diff --git a/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs b/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs index 13d4870ac5..cd1046f480 100644 --- a/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Billing.Tax.Requests; diff --git a/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs deleted file mode 100644 index c0a31efb3c..0000000000 --- a/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Bit.Core.Billing.Tax.Models; - -namespace Bit.Core.Billing.Tax.Services; - -/// -/// Responsible for defining the correct automatic tax strategy for either personal use of business use. -/// -public interface IAutomaticTaxFactory -{ - Task CreateAsync(AutomaticTaxFactoryParameters parameters); -} diff --git a/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs deleted file mode 100644 index 557bb1d30c..0000000000 --- a/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs +++ /dev/null @@ -1,33 +0,0 @@ -#nullable enable -using Stripe; - -namespace Bit.Core.Billing.Tax.Services; - -public interface IAutomaticTaxStrategy -{ - /// - /// - /// - /// - /// - /// Returns if changes are to be applied to the subscription, returns null - /// otherwise. - /// - SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription); - - /// - /// Modifies an existing object with the automatic tax flag set correctly. - /// - /// - /// - void SetCreateOptions(SubscriptionCreateOptions options, Customer customer); - - /// - /// Modifies an existing object with the automatic tax flag set correctly. - /// - /// - /// - void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription); - - void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options); -} diff --git a/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs deleted file mode 100644 index 6086a16b79..0000000000 --- a/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs +++ /dev/null @@ -1,50 +0,0 @@ -#nullable enable -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Models; -using Bit.Core.Entities; -using Bit.Core.Services; - -namespace Bit.Core.Billing.Tax.Services.Implementations; - -public class AutomaticTaxFactory( - IFeatureService featureService, - IPricingClient pricingClient) : IAutomaticTaxFactory -{ - public const string BusinessUse = "business-use"; - public const string PersonalUse = "personal-use"; - - private readonly Lazy>> _personalUsePlansTask = new(async () => - { - var plans = await Task.WhenAll( - pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), - pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually)); - - return plans.Select(plan => plan.PasswordManager.StripePlanId); - }); - - public async Task CreateAsync(AutomaticTaxFactoryParameters parameters) - { - if (parameters.Subscriber is User) - { - return new PersonalUseAutomaticTaxStrategy(featureService); - } - - if (parameters.PlanType.HasValue) - { - var plan = await pricingClient.GetPlanOrThrow(parameters.PlanType.Value); - return plan.CanBeUsedByBusiness - ? new BusinessUseAutomaticTaxStrategy(featureService) - : new PersonalUseAutomaticTaxStrategy(featureService); - } - - var personalUsePlans = await _personalUsePlansTask.Value; - - if (parameters.Prices != null && parameters.Prices.Any(x => personalUsePlans.Any(y => y == x))) - { - return new PersonalUseAutomaticTaxStrategy(featureService); - } - - return new BusinessUseAutomaticTaxStrategy(featureService); - } -} diff --git a/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs deleted file mode 100644 index 6affc57354..0000000000 --- a/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs +++ /dev/null @@ -1,96 +0,0 @@ -#nullable enable -using Bit.Core.Billing.Extensions; -using Bit.Core.Services; -using Stripe; - -namespace Bit.Core.Billing.Tax.Services.Implementations; - -public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy -{ - public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription) - { - if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - { - return null; - } - - var shouldBeEnabled = ShouldBeEnabled(subscription.Customer); - if (subscription.AutomaticTax.Enabled == shouldBeEnabled) - { - return null; - } - - var options = new SubscriptionUpdateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = shouldBeEnabled - }, - DefaultTaxRates = [] - }; - - return options; - } - - public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer) - { - options.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = ShouldBeEnabled(customer) - }; - } - - public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription) - { - if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - { - return; - } - - var shouldBeEnabled = ShouldBeEnabled(subscription.Customer); - - if (subscription.AutomaticTax.Enabled == shouldBeEnabled) - { - return; - } - - options.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = shouldBeEnabled - }; - options.DefaultTaxRates = []; - } - - public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options) - { - options.AutomaticTax ??= new InvoiceAutomaticTaxOptions(); - - if (options.CustomerDetails.Address.Country == "US") - { - options.AutomaticTax.Enabled = true; - return; - } - - options.AutomaticTax.Enabled = options.CustomerDetails.TaxIds != null && options.CustomerDetails.TaxIds.Any(); - } - - private bool ShouldBeEnabled(Customer customer) - { - if (!customer.HasRecognizedTaxLocation()) - { - return false; - } - - if (customer.Address.Country == "US") - { - return true; - } - - if (customer.TaxIds == null) - { - throw new ArgumentNullException(nameof(customer.TaxIds), "`customer.tax_ids` must be expanded."); - } - - return customer.TaxIds.Any(); - } -} diff --git a/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs deleted file mode 100644 index 615222259e..0000000000 --- a/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs +++ /dev/null @@ -1,64 +0,0 @@ -#nullable enable -using Bit.Core.Billing.Extensions; -using Bit.Core.Services; -using Stripe; - -namespace Bit.Core.Billing.Tax.Services.Implementations; - -public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy -{ - public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer) - { - options.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = ShouldBeEnabled(customer) - }; - } - - public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription) - { - if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - { - return; - } - options.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = ShouldBeEnabled(subscription.Customer) - }; - options.DefaultTaxRates = []; - } - - public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription) - { - if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - { - return null; - } - - if (subscription.AutomaticTax.Enabled == ShouldBeEnabled(subscription.Customer)) - { - return null; - } - - var options = new SubscriptionUpdateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = ShouldBeEnabled(subscription.Customer), - }, - DefaultTaxRates = [] - }; - - return options; - } - - public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options) - { - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - } - - private static bool ShouldBeEnabled(Customer customer) - { - return customer.HasRecognizedTaxLocation(); - } -} diff --git a/src/Core/Billing/Tax/Services/Implementations/TaxService.cs b/src/Core/Billing/Tax/Services/Implementations/TaxService.cs index 204c997335..55a8ab1c50 100644 --- a/src/Core/Billing/Tax/Services/Implementations/TaxService.cs +++ b/src/Core/Billing/Tax/Services/Implementations/TaxService.cs @@ -1,4 +1,7 @@ -using System.Text.RegularExpressions; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.RegularExpressions; using Bit.Core.Billing.Tax.Models; namespace Bit.Core.Billing.Tax.Services.Implementations; diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs index ebb7b0e525..2ee6b75664 100644 --- a/src/Core/Billing/Utilities.cs +++ b/src/Core/Billing/Utilities.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models; using Bit.Core.Billing.Tax.Models; using Bit.Core.Services; using Stripe; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 066d73f6d1..80b74877c5 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -1,4 +1,7 @@ -using System.Reflection; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Reflection; namespace Bit.Core; @@ -7,6 +10,11 @@ public static class Constants public const int BypassFiltersEventId = 12482444; public const int FailedSecretVerificationDelay = 2000; + /// + /// Self-hosted max storage limit in GB (10 TB). + /// + public const short SelfHostedMaxStorageGb = 10240; + // File size limits - give 1 MB extra for cushion. // Note: if request size limits are changed, 'client_max_body_size' // in nginx/proxy.conf may also need to be updated accordingly. @@ -49,6 +57,30 @@ public static class Constants /// regardless of whether there is a proration or not. /// public const string AlwaysInvoice = "always_invoice"; + + /// + /// Used primarily to determine whether a customer's business is inside or outside the United States + /// for billing purposes. + /// + public static class CountryAbbreviations + { + /// + /// Abbreviation for The United States. + /// This value must match what Stripe uses for the `Country` field value for the United States. + /// + public const string UnitedStates = "US"; + } + + + /// + /// Constants for our browser extensions IDs + /// + public static class BrowserExtensions + { + public const string ChromeId = "chrome-extension://nngceckbapebfimnlniiiahkandclblb/"; + public const string EdgeId = "chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh/"; + public const string OperaId = "chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn/"; + } } public static class AuthConstants @@ -103,25 +135,24 @@ public static class AuthenticationSchemes public static class FeatureFlagKeys { /* Admin Console Team */ - public const string AccountDeprovisioning = "pm-10308-account-deprovisioning"; - public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; - public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission"; public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; - public const string OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript"; public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; + public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; /* Auth Team */ - public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string EmailVerification = "email-verification"; - public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; public const string BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals"; public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; - public const string RecoveryCodeLogin = "pm-17128-recovery-code-login"; + public const string Otp6Digits = "pm-18612-otp-6-digits"; + public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; + public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; + public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword = + "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; @@ -145,18 +176,12 @@ public static class FeatureFlagKeys public const string TrialPayment = "PM-8163-trial-payment"; public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string UsePricingService = "use-pricing-service"; - public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; - public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; - public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup"; - public const string UseOrganizationWarningsService = "use-organization-warnings-service"; - public const string PM20322_AllowTrialLength0 = "pm-20322-allow-trial-length-0"; - public const string PM21092_SetNonUSBusinessUseToReverseCharge = "pm-21092-set-non-us-business-use-to-reverse-charge"; - public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe"; - - /* Data Insights and Reporting Team */ - public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; - public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; + public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; + public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings"; + public const string PM24996ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog"; + public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button"; + public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; @@ -167,6 +192,10 @@ public static class FeatureFlagKeys public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; public const string UserSdkForDecryption = "use-sdk-for-decryption"; public const string PM17987_BlockType0 = "pm-17987-block-type-0"; + public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings"; + public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data"; + public const string WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2"; + public const string NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change"; /* Mobile Team */ public const string NativeCarouselFlow = "native-carousel-flow"; @@ -185,21 +214,23 @@ public static class FeatureFlagKeys public const string UserManagedPrivilegedApps = "pm-18970-user-managed-privileged-apps"; public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings"; public const string AppIntents = "app-intents"; + public const string SendAccess = "pm-19394-send-access-control"; + public const string CxpImportMobile = "cxp-import-mobile"; + public const string CxpExportMobile = "cxp-export-mobile"; /* Platform Team */ - public const string PersistPopupView = "persist-popup-view"; - public const string StorageReseedRefactor = "storage-reseed-refactor"; - public const string WebPush = "web-push"; - public const string RecordInstallationLastActivityDate = "installation-last-activity-date"; public const string IpcChannelFramework = "ipc-channel-framework"; + public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; + public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users"; /* Tools Team */ public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; + public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators"; + public const string UseChromiumImporter = "pm-23982-chromium-importer"; /* Vault Team */ public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; - public const string SecurityTasks = "security-tasks"; public const string CipherKeyEncryption = "cipher-key-encryption"; public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; @@ -208,6 +239,13 @@ public static class FeatureFlagKeys public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy"; public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view"; public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp"; + public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption"; + + /* Innovation Team */ + public const string ArchiveVaultItems = "pm-19148-innovation-archive"; + + /* DIRT Team */ + public const string PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab"; public static List GetAllKeys() { diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index 68d4606907..5d9b5a1759 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -1,12 +1,15 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Core.AdminConsole.Context; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Billing.Extensions; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Settings; @@ -15,10 +18,10 @@ using Microsoft.AspNetCore.Http; namespace Bit.Core.Context; -public class CurrentContext : ICurrentContext +public class CurrentContext( + IProviderOrganizationRepository _providerOrganizationRepository, + IProviderUserRepository _providerUserRepository) : ICurrentContext { - private readonly IProviderOrganizationRepository _providerOrganizationRepository; - private readonly IProviderUserRepository _providerUserRepository; private bool _builtHttpContext; private bool _builtClaimsPrincipal; private IEnumerable _providerOrganizationProviderDetails; @@ -45,14 +48,6 @@ public class CurrentContext : ICurrentContext public virtual IdentityClientType IdentityClientType { get; set; } public virtual Guid? ServiceAccountOrganizationId { get; set; } - public CurrentContext( - IProviderOrganizationRepository providerOrganizationRepository, - IProviderUserRepository providerUserRepository) - { - _providerOrganizationRepository = providerOrganizationRepository; - _providerUserRepository = providerUserRepository; - } - public async virtual Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings) { if (_builtHttpContext) @@ -134,6 +129,24 @@ public class CurrentContext : ICurrentContext var claimsDict = user.Claims.GroupBy(c => c.Type).ToDictionary(c => c.Key, c => c.Select(v => v)); + ClientId = GetClaimValue(claimsDict, "client_id"); + + var clientType = GetClaimValue(claimsDict, Claims.Type); + if (clientType != null) + { + if (Enum.TryParse(clientType, out IdentityClientType c)) + { + IdentityClientType = c; + } + } + + if (IdentityClientType == IdentityClientType.Send) + { + // For the Send client, we don't need to set any User specific properties on the context + // so just short circuit and return here. + return Task.FromResult(0); + } + var subject = GetClaimValue(claimsDict, "sub"); if (Guid.TryParse(subject, out var subIdGuid)) { @@ -162,13 +175,6 @@ public class CurrentContext : ICurrentContext } } - var clientType = GetClaimValue(claimsDict, Claims.Type); - if (clientType != null) - { - Enum.TryParse(clientType, out IdentityClientType c); - IdentityClientType = c; - } - if (IdentityClientType == IdentityClientType.ServiceAccount) { ServiceAccountOrganizationId = new Guid(GetClaimValue(claimsDict, Claims.Organization)); diff --git a/src/Core/Context/ICurrentContext.cs b/src/Core/Context/ICurrentContext.cs index 42843ce6d7..417e220ba2 100644 --- a/src/Core/Context/ICurrentContext.cs +++ b/src/Core/Context/ICurrentContext.cs @@ -3,9 +3,9 @@ using System.Security.Claims; using Bit.Core.AdminConsole.Context; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Repositories; using Bit.Core.Settings; using Microsoft.AspNetCore.Http; diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index e3141c7bfb..e9bf1b1807 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + @@ -30,13 +30,13 @@ - + - + - + @@ -50,7 +50,7 @@ - + @@ -59,7 +59,7 @@ - + diff --git a/src/Core/Dirt/Entities/OrganizationApplication.cs b/src/Core/Dirt/Entities/OrganizationApplication.cs index 259dbd60dd..48a6ef4257 100644 --- a/src/Core/Dirt/Entities/OrganizationApplication.cs +++ b/src/Core/Dirt/Entities/OrganizationApplication.cs @@ -12,6 +12,7 @@ public class OrganizationApplication : ITableObject, IRevisable public string Applications { get; set; } = string.Empty; public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public string ContentEncryptionKey { get; set; } = string.Empty; public void SetNewId() { diff --git a/src/Core/Dirt/Entities/OrganizationReport.cs b/src/Core/Dirt/Entities/OrganizationReport.cs index 0f327c5c8f..a776648b35 100644 --- a/src/Core/Dirt/Entities/OrganizationReport.cs +++ b/src/Core/Dirt/Entities/OrganizationReport.cs @@ -9,10 +9,15 @@ public class OrganizationReport : ITableObject { public Guid Id { get; set; } public Guid OrganizationId { get; set; } - public DateTime Date { get; set; } public string ReportData { get; set; } = string.Empty; public DateTime CreationDate { get; set; } = DateTime.UtcNow; + public string ContentEncryptionKey { get; set; } = string.Empty; + + public string? SummaryData { get; set; } = null; + public string? ApplicationData { get; set; } = null; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public void SetNewId() { Id = CoreHelpers.GenerateComb(); diff --git a/src/Core/Dirt/Models/Data/MemberAccessCipherDetails.cs b/src/Core/Dirt/Models/Data/MemberAccessCipherDetails.cs index c1949ffb24..326a7c61cb 100644 --- a/src/Core/Dirt/Models/Data/MemberAccessCipherDetails.cs +++ b/src/Core/Dirt/Models/Data/MemberAccessCipherDetails.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Dirt.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Models.Data; public class MemberAccessDetails { diff --git a/src/Core/Dirt/Models/Data/MemberAccessReportDetail.cs b/src/Core/Dirt/Models/Data/MemberAccessReportDetail.cs index a99b6e2088..7b54822a1e 100644 --- a/src/Core/Dirt/Models/Data/MemberAccessReportDetail.cs +++ b/src/Core/Dirt/Models/Data/MemberAccessReportDetail.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Dirt.Reports.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.Models.Data; public class MemberAccessReportDetail { diff --git a/src/Core/Dirt/Models/Data/OrganizationMemberBaseDetail.cs b/src/Core/Dirt/Models/Data/OrganizationMemberBaseDetail.cs index a1f0bd81fd..a68e920e66 100644 --- a/src/Core/Dirt/Models/Data/OrganizationMemberBaseDetail.cs +++ b/src/Core/Dirt/Models/Data/OrganizationMemberBaseDetail.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Dirt.Reports.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.Models.Data; public class OrganizationMemberBaseDetail { diff --git a/src/Core/Dirt/Models/Data/OrganizationReportApplicationDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportApplicationDataResponse.cs new file mode 100644 index 0000000000..292d8e6f38 --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationReportApplicationDataResponse.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Dirt.Models.Data; + +public class OrganizationReportApplicationDataResponse +{ + public string? ApplicationData { get; set; } +} diff --git a/src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs new file mode 100644 index 0000000000..c284d99ff2 --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Dirt.Models.Data; + +public class OrganizationReportDataResponse +{ + public string? ReportData { get; set; } +} diff --git a/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs new file mode 100644 index 0000000000..0533c2862f --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Dirt.Models.Data; + +public class OrganizationReportSummaryDataResponse +{ + public string? SummaryData { get; set; } +} diff --git a/src/Core/Dirt/Models/Data/RiskInsightsReportDetail.cs b/src/Core/Dirt/Models/Data/RiskInsightsReportDetail.cs index 1ea805edf1..acc468eb11 100644 --- a/src/Core/Dirt/Models/Data/RiskInsightsReportDetail.cs +++ b/src/Core/Dirt/Models/Data/RiskInsightsReportDetail.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Dirt.Reports.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.Models.Data; public class RiskInsightsReportDetail { diff --git a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs index 66d25cdf56..f0477806d8 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs @@ -26,12 +26,12 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand public async Task AddOrganizationReportAsync(AddOrganizationReportRequest request) { - _logger.LogInformation("Adding organization report for organization {organizationId}", request.OrganizationId); + _logger.LogInformation(Constants.BypassFiltersEventId, "Adding organization report for organization {organizationId}", request.OrganizationId); var (isValid, errorMessage) = await ValidateRequestAsync(request); if (!isValid) { - _logger.LogInformation("Failed to add organization {organizationId} report: {errorMessage}", request.OrganizationId, errorMessage); + _logger.LogInformation(Constants.BypassFiltersEventId, "Failed to add organization {organizationId} report: {errorMessage}", request.OrganizationId, errorMessage); throw new BadRequestException(errorMessage); } @@ -39,15 +39,18 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand { OrganizationId = request.OrganizationId, ReportData = request.ReportData, - Date = request.Date == default ? DateTime.UtcNow : request.Date, CreationDate = DateTime.UtcNow, + ContentEncryptionKey = request.ContentEncryptionKey, + SummaryData = request.SummaryData, + ApplicationData = request.ApplicationData, + RevisionDate = DateTime.UtcNow }; organizationReport.SetNewId(); var data = await _organizationReportRepo.CreateAsync(organizationReport); - _logger.LogInformation("Successfully added organization report for organization {organizationId}, {organizationReportId}", + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully added organization report for organization {organizationId}, {organizationReportId}", request.OrganizationId, data.Id); return data; @@ -63,12 +66,26 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand return (false, "Invalid Organization"); } - // ensure that we have report data + if (string.IsNullOrWhiteSpace(request.ContentEncryptionKey)) + { + return (false, "Content Encryption Key is required"); + } + if (string.IsNullOrWhiteSpace(request.ReportData)) { return (false, "Report Data is required"); } + if (string.IsNullOrWhiteSpace(request.SummaryData)) + { + return (false, "Summary Data is required"); + } + + if (string.IsNullOrWhiteSpace(request.ApplicationData)) + { + return (false, "Application Data is required"); + } + return (true, string.Empty); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs deleted file mode 100644 index 8fe206c1f1..0000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Dirt.Reports.ReportFeatures; - -public class DropOrganizationReportCommand : IDropOrganizationReportCommand -{ - private IOrganizationReportRepository _organizationReportRepo; - private ILogger _logger; - - public DropOrganizationReportCommand( - IOrganizationReportRepository organizationReportRepository, - ILogger logger) - { - _organizationReportRepo = organizationReportRepository; - _logger = logger; - } - - public async Task DropOrganizationReportAsync(DropOrganizationReportRequest request) - { - _logger.LogInformation("Dropping organization report for organization {organizationId}", - request.OrganizationId); - - var data = await _organizationReportRepo.GetByOrganizationIdAsync(request.OrganizationId); - if (data == null || data.Count() == 0) - { - _logger.LogInformation("No organization reports found for organization {organizationId}", request.OrganizationId); - throw new BadRequestException("No data found."); - } - - data - .Where(_ => request.OrganizationReportIds.Contains(_.Id)) - .ToList() - .ForEach(async reportId => - { - _logger.LogInformation("Dropping organization report {organizationReportId} for organization {organizationId}", - reportId, request.OrganizationId); - - await _organizationReportRepo.DeleteAsync(reportId); - }); - } -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs new file mode 100644 index 0000000000..983fa71fd7 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs @@ -0,0 +1,62 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportApplicationDataQuery : IGetOrganizationReportApplicationDataQuery +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public GetOrganizationReportApplicationDataQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report application data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + if (organizationId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportApplicationDataAsync called with empty OrganizationId"); + throw new BadRequestException("OrganizationId is required."); + } + + if (reportId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportApplicationDataAsync called with empty ReportId"); + throw new BadRequestException("ReportId is required."); + } + + var applicationDataResponse = await _organizationReportRepo.GetApplicationDataAsync(reportId); + + if (applicationDataResponse == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "No application data found for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw new NotFoundException("Organization report application data not found."); + } + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved organization report application data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + return applicationDataResponse; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error fetching organization report application data for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw; + } + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs new file mode 100644 index 0000000000..d53fa56111 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs @@ -0,0 +1,62 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportDataQuery : IGetOrganizationReportDataQuery +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public GetOrganizationReportDataQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + if (organizationId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportDataAsync called with empty OrganizationId"); + throw new BadRequestException("OrganizationId is required."); + } + + if (reportId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportDataAsync called with empty ReportId"); + throw new BadRequestException("ReportId is required."); + } + + var reportDataResponse = await _organizationReportRepo.GetReportDataAsync(reportId); + + if (reportDataResponse == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "No report data found for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw new NotFoundException("Organization report data not found."); + } + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved organization report data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + return reportDataResponse; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error fetching organization report data for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw; + } + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs index e536fdfddc..b0bf9e450a 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs @@ -19,15 +19,23 @@ public class GetOrganizationReportQuery : IGetOrganizationReportQuery _logger = logger; } - public async Task> GetOrganizationReportAsync(Guid organizationId) + public async Task GetOrganizationReportAsync(Guid reportId) { - if (organizationId == Guid.Empty) + if (reportId == Guid.Empty) { - throw new BadRequestException("OrganizationId is required."); + throw new BadRequestException("Id of report is required."); } - _logger.LogInformation("Fetching organization reports for organization {organizationId}", organizationId); - return await _organizationReportRepo.GetByOrganizationIdAsync(organizationId); + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization reports for organization by Id: {reportId}", reportId); + + var results = await _organizationReportRepo.GetByIdAsync(reportId); + + if (results == null) + { + throw new NotFoundException($"No report found for Id: {reportId}"); + } + + return results; } public async Task GetLatestOrganizationReportAsync(Guid organizationId) @@ -37,7 +45,7 @@ public class GetOrganizationReportQuery : IGetOrganizationReportQuery throw new BadRequestException("OrganizationId is required."); } - _logger.LogInformation("Fetching latest organization report for organization {organizationId}", organizationId); + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching latest organization report for organization {organizationId}", organizationId); return await _organizationReportRepo.GetLatestByOrganizationIdAsync(organizationId); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs new file mode 100644 index 0000000000..7be59b822e --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs @@ -0,0 +1,89 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportSummaryDataByDateRangeQuery : IGetOrganizationReportSummaryDataByDateRangeQuery +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public GetOrganizationReportSummaryDataByDateRangeQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task> GetOrganizationReportSummaryDataByDateRangeAsync(Guid organizationId, DateTime startDate, DateTime endDate) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report summary data by date range for organization {organizationId}, from {startDate} to {endDate}", + organizationId, startDate, endDate); + + var (isValid, errorMessage) = ValidateRequest(organizationId, startDate, endDate); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportSummaryDataByDateRangeAsync validation failed: {errorMessage}", errorMessage); + throw new BadRequestException(errorMessage); + } + + IEnumerable summaryDataList = (await _organizationReportRepo + .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate)) ?? + Enumerable.Empty(); + + var resultList = summaryDataList.ToList(); + + if (!resultList.Any()) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "No summary data found for organization {organizationId} in date range {startDate} to {endDate}", + organizationId, startDate, endDate); + return Enumerable.Empty(); + } + else + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved {count} organization report summary data records for organization {organizationId} in date range {startDate} to {endDate}", + resultList.Count, organizationId, startDate, endDate); + + } + + return resultList; + } + catch (Exception ex) when (!(ex is BadRequestException)) + { + _logger.LogError(ex, "Error fetching organization report summary data by date range for organization {organizationId}, from {startDate} to {endDate}", + organizationId, startDate, endDate); + throw; + } + } + + private static (bool IsValid, string errorMessage) ValidateRequest(Guid organizationId, DateTime startDate, DateTime endDate) + { + if (organizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (startDate == default) + { + return (false, "StartDate is required"); + } + + if (endDate == default) + { + return (false, "EndDate is required"); + } + + if (startDate > endDate) + { + return (false, "StartDate must be earlier than or equal to EndDate"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs new file mode 100644 index 0000000000..83ee24a476 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs @@ -0,0 +1,62 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportSummaryDataQuery : IGetOrganizationReportSummaryDataQuery +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public GetOrganizationReportSummaryDataQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task GetOrganizationReportSummaryDataAsync(Guid organizationId, Guid reportId) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report summary data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + if (organizationId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportSummaryDataAsync called with empty OrganizationId"); + throw new BadRequestException("OrganizationId is required."); + } + + if (reportId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportSummaryDataAsync called with empty ReportId"); + throw new BadRequestException("ReportId is required."); + } + + var summaryDataResponse = await _organizationReportRepo.GetSummaryDataAsync(reportId); + + if (summaryDataResponse == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "No summary data found for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw new NotFoundException("Organization report summary data not found."); + } + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved organization report summary data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + return summaryDataResponse; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error fetching organization report summary data for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw; + } + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs deleted file mode 100644 index 1ed9059f56..0000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ - -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; - -public interface IDropOrganizationReportCommand -{ - Task DropOrganizationReportAsync(DropOrganizationReportRequest request); -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportApplicationDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportApplicationDataQuery.cs new file mode 100644 index 0000000000..f7eceea583 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportApplicationDataQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportApplicationDataQuery +{ + Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs new file mode 100644 index 0000000000..3817fa03d2 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportDataQuery +{ + Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs index f596e8f517..b72fdd25b5 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs @@ -4,6 +4,6 @@ namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; public interface IGetOrganizationReportQuery { - Task> GetOrganizationReportAsync(Guid organizationId); + Task GetOrganizationReportAsync(Guid organizationId); Task GetLatestOrganizationReportAsync(Guid organizationId); } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataByDateRangeQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataByDateRangeQuery.cs new file mode 100644 index 0000000000..2659a3d78b --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataByDateRangeQuery.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportSummaryDataByDateRangeQuery +{ + Task> GetOrganizationReportSummaryDataByDateRangeAsync( + Guid organizationId, DateTime startDate, DateTime endDate); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataQuery.cs new file mode 100644 index 0000000000..8b208c8a8a --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportSummaryDataQuery +{ + Task GetOrganizationReportSummaryDataAsync(Guid organizationId, Guid reportId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportApplicationDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportApplicationDataCommand.cs new file mode 100644 index 0000000000..352de679be --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportApplicationDataCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportApplicationDataCommand +{ + Task UpdateOrganizationReportApplicationDataAsync(UpdateOrganizationReportApplicationDataRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportCommand.cs new file mode 100644 index 0000000000..fc947b9f9d --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportCommand +{ + Task UpdateOrganizationReportAsync(UpdateOrganizationReportRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs new file mode 100644 index 0000000000..cb212714f2 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportDataCommand +{ + Task UpdateOrganizationReportDataAsync(UpdateOrganizationReportDataRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportSummaryCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportSummaryCommand.cs new file mode 100644 index 0000000000..bdc2081a1f --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportSummaryCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportSummaryCommand +{ + Task UpdateOrganizationReportSummaryAsync(UpdateOrganizationReportSummaryRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs index 21dbfc77a4..83d074454d 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs @@ -1,28 +1,46 @@ -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Reports.Repositories; using Bit.Core.Services; +using Microsoft.Extensions.Logging; namespace Bit.Core.Dirt.Reports.ReportFeatures; public class MemberAccessReportQuery( IOrganizationMemberBaseDetailRepository organizationMemberBaseDetailRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IApplicationCacheService applicationCacheService) : IMemberAccessReportQuery + IApplicationCacheService applicationCacheService, + ILogger logger) : IMemberAccessReportQuery { public async Task> GetMemberAccessReportsAsync( MemberAccessReportRequest request) { + logger.LogInformation(Constants.BypassFiltersEventId, "Starting MemberAccessReport generation for OrganizationId: {OrganizationId}", request.OrganizationId); + var baseDetails = await organizationMemberBaseDetailRepository.GetOrganizationMemberBaseDetailsByOrganizationId( request.OrganizationId); + logger.LogInformation(Constants.BypassFiltersEventId, "Retrieved {BaseDetailsCount} base details for OrganizationId: {OrganizationId}", + baseDetails.Count(), request.OrganizationId); + var orgUsers = baseDetails.Select(x => x.UserGuid.GetValueOrDefault()).Distinct(); + var orgUsersCount = orgUsers.Count(); + logger.LogInformation(Constants.BypassFiltersEventId, "Found {UniqueUsersCount} unique users for OrganizationId: {OrganizationId}", + orgUsersCount, request.OrganizationId); + var orgUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); + logger.LogInformation(Constants.BypassFiltersEventId, "Retrieved two-factor status for {UsersCount} users for OrganizationId: {OrganizationId}", + orgUsersTwoFactorEnabled.Count(), request.OrganizationId); var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId); + logger.LogInformation(Constants.BypassFiltersEventId, "Retrieved organization ability (UseResetPassword: {UseResetPassword}) for OrganizationId: {OrganizationId}", + orgAbility?.UseResetPassword, request.OrganizationId); var accessDetails = baseDetails .GroupBy(b => new @@ -59,6 +77,10 @@ public class MemberAccessReportQuery( CipherIds = g.Select(c => c.CipherId) }); + var accessDetailsCount = accessDetails.Count(); + logger.LogInformation(Constants.BypassFiltersEventId, "Completed MemberAccessReport generation for OrganizationId: {OrganizationId}. Generated {AccessDetailsCount} access detail records", + request.OrganizationId, accessDetailsCount); + return accessDetails; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index a20c7a3e8f..f89ff97762 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -14,7 +14,14 @@ public static class ReportingServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs index ca892cddde..2a8c0203f9 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs @@ -1,8 +1,16 @@ -namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class AddOrganizationReportRequest { public Guid OrganizationId { get; set; } public string ReportData { get; set; } - public DateTime Date { get; set; } + + public string ContentEncryptionKey { get; set; } + + public string SummaryData { get; set; } + + public string ApplicationData { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs index c4e646fcd7..884c7ea40a 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class AddPasswordHealthReportApplicationRequest { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/DropOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/DropOrganizationReportRequest.cs index cc889fe351..04dc4b43a2 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/DropOrganizationReportRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/DropOrganizationReportRequest.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class DropOrganizationReportRequest { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs index 544b9a51d5..3fc09af574 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class DropPasswordHealthReportApplicationRequest { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/GetOrganizationReportSummaryDataByDateRangeRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/GetOrganizationReportSummaryDataByDateRangeRequest.cs new file mode 100644 index 0000000000..8949cfdff3 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/GetOrganizationReportSummaryDataByDateRangeRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class GetOrganizationReportSummaryDataByDateRangeRequest +{ + public Guid OrganizationId { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs new file mode 100644 index 0000000000..ab4fcc5921 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportApplicationDataRequest +{ + public Guid Id { get; set; } + public Guid OrganizationId { get; set; } + public string ApplicationData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs new file mode 100644 index 0000000000..673a3f2ab8 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportDataRequest +{ + public Guid OrganizationId { get; set; } + public Guid ReportId { get; set; } + public string ReportData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportRequest.cs new file mode 100644 index 0000000000..501f5a1a1a --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportRequest.cs @@ -0,0 +1,14 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportRequest +{ + public Guid ReportId { get; set; } + public Guid OrganizationId { get; set; } + public string ReportData { get; set; } + public string ContentEncryptionKey { get; set; } + public string SummaryData { get; set; } = null; + public string ApplicationData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs new file mode 100644 index 0000000000..b0e555fcef --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportSummaryRequest +{ + public Guid OrganizationId { get; set; } + public Guid ReportId { get; set; } + public string SummaryData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs new file mode 100644 index 0000000000..67ec49d004 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs @@ -0,0 +1,96 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportApplicationDataCommand : IUpdateOrganizationReportApplicationDataCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportApplicationDataCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateOrganizationReportApplicationDataAsync(UpdateOrganizationReportApplicationDataRequest request) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report application data {reportId} for organization {organizationId}", + request.Id, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report application data {reportId} for organization {organizationId}: {errorMessage}", + request.Id, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.Id); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found", request.Id); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} does not belong to organization {organizationId}", + request.Id, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + var updatedReport = await _organizationReportRepo.UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report application data {reportId} for organization {organizationId}", + request.Id, request.OrganizationId); + + return updatedReport; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error updating organization report application data {reportId} for organization {organizationId}", + request.Id, request.OrganizationId); + throw; + } + } + + private async Task<(bool isValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportApplicationDataRequest request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.Id == Guid.Empty) + { + return (false, "Id is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.ApplicationData)) + { + return (false, "Application Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs new file mode 100644 index 0000000000..7fb77030a8 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs @@ -0,0 +1,124 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportCommand : IUpdateOrganizationReportCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateOrganizationReportAsync(UpdateOrganizationReportRequest request) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report {reportId} for organization {organizationId}: {errorMessage}", + request.ReportId, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found", request.ReportId); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} does not belong to organization {organizationId}", + request.ReportId, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + existingReport.ContentEncryptionKey = request.ContentEncryptionKey; + existingReport.SummaryData = request.SummaryData; + existingReport.ReportData = request.ReportData; + existingReport.ApplicationData = request.ApplicationData; + existingReport.RevisionDate = DateTime.UtcNow; + + await _organizationReportRepo.UpsertAsync(existingReport); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var response = await _organizationReportRepo.GetByIdAsync(request.ReportId); + + if (response == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found after update", request.ReportId); + throw new NotFoundException("Organization report not found after update"); + } + return response; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error updating organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + throw; + } + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportRequest request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.ReportId == Guid.Empty) + { + return (false, "ReportId is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.ContentEncryptionKey)) + { + return (false, "ContentEncryptionKey is required"); + } + + if (string.IsNullOrWhiteSpace(request.ReportData)) + { + return (false, "Report Data is required"); + } + + if (string.IsNullOrWhiteSpace(request.SummaryData)) + { + return (false, "Summary Data is required"); + } + + if (string.IsNullOrWhiteSpace(request.ApplicationData)) + { + return (false, "Application Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs new file mode 100644 index 0000000000..f81d24c3d7 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs @@ -0,0 +1,96 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportDataCommand : IUpdateOrganizationReportDataCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportDataCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateOrganizationReportDataAsync(UpdateOrganizationReportDataRequest request) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report data {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report data {reportId} for organization {organizationId}: {errorMessage}", + request.ReportId, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found", request.ReportId); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} does not belong to organization {organizationId}", + request.ReportId, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + var updatedReport = await _organizationReportRepo.UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report data {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + return updatedReport; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error updating organization report data {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + throw; + } + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportDataRequest request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.ReportId == Guid.Empty) + { + return (false, "ReportId is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.ReportData)) + { + return (false, "Report Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs new file mode 100644 index 0000000000..6859814d65 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs @@ -0,0 +1,96 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportSummaryCommand : IUpdateOrganizationReportSummaryCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportSummaryCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateOrganizationReportSummaryAsync(UpdateOrganizationReportSummaryRequest request) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report summary {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report summary {reportId} for organization {organizationId}: {errorMessage}", + request.ReportId, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found", request.ReportId); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} does not belong to organization {organizationId}", + request.ReportId, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + var updatedReport = await _organizationReportRepo.UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report summary {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + return updatedReport; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error updating organization report summary {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + throw; + } + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportSummaryRequest request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.ReportId == Guid.Empty) + { + return (false, "ReportId is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.SummaryData)) + { + return (false, "Summary Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs index e7979ca4b7..9687173716 100644 --- a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs +++ b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs @@ -1,12 +1,25 @@ using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Repositories; namespace Bit.Core.Dirt.Repositories; public interface IOrganizationReportRepository : IRepository { - Task> GetByOrganizationIdAsync(Guid organizationId); - + // Whole OrganizationReport methods Task GetLatestByOrganizationIdAsync(Guid organizationId); + + // SummaryData methods + Task> GetSummaryDataByDateRangeAsync(Guid organizationId, DateTime startDate, DateTime endDate); + Task GetSummaryDataAsync(Guid reportId); + Task UpdateSummaryDataAsync(Guid orgId, Guid reportId, string summaryData); + + // ReportData methods + Task GetReportDataAsync(Guid reportId); + Task UpdateReportDataAsync(Guid orgId, Guid reportId, string reportData); + + // ApplicationData methods + Task GetApplicationDataAsync(Guid reportId); + Task UpdateApplicationDataAsync(Guid orgId, Guid reportId, string applicationData); } diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index b92d22b0e3..12c527ed78 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -78,6 +78,11 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public DateTime? LastEmailChangeDate { get; set; } public bool VerifyDevices { get; set; } = true; + public string GetMasterPasswordSalt() + { + return Email.ToLowerInvariant().Trim(); + } + public void SetNewId() { Id = CoreHelpers.GenerateComb(); diff --git a/src/Core/Enums/AccessClientType.cs b/src/Core/Enums/AccessClientType.cs index fb757c6dd6..c7336ee40d 100644 --- a/src/Core/Enums/AccessClientType.cs +++ b/src/Core/Enums/AccessClientType.cs @@ -1,4 +1,4 @@ -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; namespace Bit.Core.Enums; diff --git a/src/Core/Enums/BitwardenClient.cs b/src/Core/Enums/BitwardenClient.cs index 6a1244c0c4..4776e0de3f 100644 --- a/src/Core/Enums/BitwardenClient.cs +++ b/src/Core/Enums/BitwardenClient.cs @@ -8,5 +8,6 @@ public static class BitwardenClient Desktop = "desktop", Mobile = "mobile", Cli = "cli", - DirectoryConnector = "connector"; + DirectoryConnector = "connector", + Send = "send"; } diff --git a/src/Core/Enums/DeviceType.cs b/src/Core/Enums/DeviceType.cs index 9679088509..9f55f50bc0 100644 --- a/src/Core/Enums/DeviceType.cs +++ b/src/Core/Enums/DeviceType.cs @@ -55,5 +55,7 @@ public enum DeviceType : byte [Display(Name = "MacOs CLI")] MacOsCLI = 24, [Display(Name = "Linux CLI")] - LinuxCLI = 25 + LinuxCLI = 25, + [Display(Name = "DuckDuckGo")] + DuckDuckGoBrowser = 26, } diff --git a/src/Core/Enums/PushType.cs b/src/Core/Enums/PushType.cs deleted file mode 100644 index 96a1192478..0000000000 --- a/src/Core/Enums/PushType.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Bit.Core.Enums; - -public enum PushType : byte -{ - SyncCipherUpdate = 0, - SyncCipherCreate = 1, - SyncLoginDelete = 2, - SyncFolderDelete = 3, - SyncCiphers = 4, - - SyncVault = 5, - SyncOrgKeys = 6, - SyncFolderCreate = 7, - SyncFolderUpdate = 8, - SyncCipherDelete = 9, - SyncSettings = 10, - - LogOut = 11, - - SyncSendCreate = 12, - SyncSendUpdate = 13, - SyncSendDelete = 14, - - AuthRequest = 15, - AuthRequestResponse = 16, - - SyncOrganizations = 17, - SyncOrganizationStatusChanged = 18, - SyncOrganizationCollectionSettingChanged = 19, - - Notification = 20, - NotificationStatus = 21, - - PendingSecurityTasks = 22 -} diff --git a/src/Core/HostedServices/ApplicationCacheHostedService.cs b/src/Core/HostedServices/ApplicationCacheHostedService.cs index a699a26fcc..655a713764 100644 --- a/src/Core/HostedServices/ApplicationCacheHostedService.cs +++ b/src/Core/HostedServices/ApplicationCacheHostedService.cs @@ -3,6 +3,7 @@ using Azure.Messaging.ServiceBus.Administration; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Services.Implementations; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.Extensions.Hosting; @@ -14,7 +15,7 @@ namespace Bit.Core.HostedServices; public class ApplicationCacheHostedService : IHostedService, IDisposable { - private readonly InMemoryServiceBusApplicationCacheService? _applicationCacheService; + private readonly FeatureRoutedCacheService? _applicationCacheService; private readonly IOrganizationRepository _organizationRepository; protected readonly ILogger _logger; private readonly ServiceBusClient _serviceBusClient; @@ -34,7 +35,7 @@ public class ApplicationCacheHostedService : IHostedService, IDisposable { _topicName = globalSettings.ServiceBus.ApplicationCacheTopicName; _subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings); - _applicationCacheService = applicationCacheService as InMemoryServiceBusApplicationCacheService; + _applicationCacheService = applicationCacheService as FeatureRoutedCacheService; _organizationRepository = organizationRepository; _logger = logger; _serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString); @@ -67,19 +68,25 @@ public class ApplicationCacheHostedService : IHostedService, IDisposable public virtual async Task StopAsync(CancellationToken cancellationToken) { + // Step 1: Signal ExecuteAsync to stop gracefully + _cts?.Cancel(); + + // Step 2: Wait for ExecuteAsync to finish cleanly + if (_executingTask != null) + { + await _executingTask; + } + + // Step 3: Now safely dispose resources (ExecuteAsync is done) await _subscriptionReceiver.CloseAsync(cancellationToken); await _serviceBusClient.DisposeAsync(); - _cts?.Cancel(); + + // Step 4: Clean up subscription try { await _serviceBusAdministrationClient.DeleteSubscriptionAsync(_topicName, _subName, cancellationToken); } catch { } - - if (_executingTask != null) - { - await _executingTask; - } } public virtual void Dispose() @@ -87,15 +94,39 @@ public class ApplicationCacheHostedService : IHostedService, IDisposable private async Task ExecuteAsync(CancellationToken cancellationToken) { - await foreach (var message in _subscriptionReceiver.ReceiveMessagesAsync(cancellationToken)) + while (!cancellationToken.IsCancellationRequested) { try { - await ProcessMessageAsync(message, cancellationToken); + var messages = await _subscriptionReceiver.ReceiveMessagesAsync( + maxMessages: 1, + maxWaitTime: TimeSpan.FromSeconds(30), + cancellationToken); + + if (messages?.Any() == true) + { + foreach (var message in messages) + { + try + { + await ProcessMessageAsync(message, cancellationToken); + } + catch (Exception e) + { + _logger.LogError(e, "Error processing messages in ApplicationCacheHostedService"); + } + } + } } - catch (Exception e) + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) { - _logger.LogError(e, "Error processing messages in ApplicationCacheHostedService"); + _logger.LogDebug("ServiceBus receiver disposed during Alpine container shutdown"); + break; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug("ServiceBus operation cancelled during Alpine container shutdown"); + break; } } } diff --git a/src/Core/Jobs/BaseJobsHostedService.cs b/src/Core/Jobs/BaseJobsHostedService.cs index 2ade53c6bb..3e7bce7e0f 100644 --- a/src/Core/Jobs/BaseJobsHostedService.cs +++ b/src/Core/Jobs/BaseJobsHostedService.cs @@ -109,7 +109,7 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable _logger.LogWarning($"Exception while trying to schedule job: {job.FullName}, {e}"); var random = new Random(); - Thread.Sleep(random.Next(50, 250)); + await Task.Delay(random.Next(50, 250)); } } } diff --git a/src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs new file mode 100644 index 0000000000..081ae5248d --- /dev/null +++ b/src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs @@ -0,0 +1,15 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.Kdf; + +/// +/// Command to change the Key Derivation Function (KDF) settings for a user. This includes +/// changing the masterpassword authentication hash, and the masterkey encrypted userkey. +/// The salt must not change during the KDF change. +/// +public interface IChangeKdfCommand +{ + public Task ChangeKdfAsync(User user, string masterPasswordAuthenticationHash, MasterPasswordAuthenticationData authenticationData, MasterPasswordUnlockData unlockData); +} diff --git a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs new file mode 100644 index 0000000000..fe736f9ac6 --- /dev/null +++ b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs @@ -0,0 +1,94 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.KeyManagement.Kdf.Implementations; + +/// +public class ChangeKdfCommand : IChangeKdfCommand +{ + private readonly IUserService _userService; + private readonly IPushNotificationService _pushService; + private readonly IUserRepository _userRepository; + private readonly IdentityErrorDescriber _identityErrorDescriber; + private readonly ILogger _logger; + + public ChangeKdfCommand(IUserService userService, IPushNotificationService pushService, IUserRepository userRepository, IdentityErrorDescriber describer, ILogger logger) + { + _userService = userService; + _pushService = pushService; + _userRepository = userRepository; + _identityErrorDescriber = describer; + _logger = logger; + } + + public async Task ChangeKdfAsync(User user, string masterPasswordAuthenticationHash, MasterPasswordAuthenticationData authenticationData, MasterPasswordUnlockData unlockData) + { + ArgumentNullException.ThrowIfNull(user); + if (!await _userService.CheckPasswordAsync(user, masterPasswordAuthenticationHash)) + { + return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); + } + + // Validate to prevent user account from becoming un-decryptable from invalid parameters + // + // Prevent a de-synced salt value from creating an un-decryptable unlock method + authenticationData.ValidateSaltUnchangedForUser(user); + unlockData.ValidateSaltUnchangedForUser(user); + + // Currently KDF settings are not saved separately for authentication and unlock and must therefore be equal + if (!authenticationData.Kdf.Equals(unlockData.Kdf)) + { + throw new BadRequestException("KDF settings must be equal for authentication and unlock."); + } + var validationErrors = KdfSettingsValidator.Validate(unlockData.Kdf); + if (validationErrors.Any()) + { + throw new BadRequestException("KDF settings are invalid."); + } + + // Update the user with the new KDF settings + // This updates the authentication data and unlock data for the user separately. Currently these still + // use shared values for KDF settings and salt. + // The authentication hash, and the unlock data each are dependent on: + // - The master password (entered by the user every time) + // - The KDF settings (iterations, memory, parallelism) + // - The salt + // These combinations - (password, authentication hash, KDF settings, salt) and (password, unlock data, KDF settings, salt) + // must remain consistent to unlock correctly. + + // Authentication + // Note: This mutates the user but does not yet save it to DB. That is done atomically, later. + // This entire operation MUST be atomic to prevent a user from being locked out of their account. + // Salt is ensured to be the same as unlock data, and the value stored in the account and not updated. + // KDF is ensured to be the same as unlock data above and updated below. + var result = await _userService.UpdatePasswordHash(user, authenticationData.MasterPasswordAuthenticationHash); + if (!result.Succeeded) + { + _logger.LogWarning("Change KDF failed for user {userId}.", user.Id); + return result; + } + + // Salt is ensured to be the same as authentication data, and the value stored in the account, and is not updated. + // Kdf - These will be seperated in the future, but for now are ensured to be the same as authentication data above. + user.Key = unlockData.MasterKeyWrappedUserKey; + user.Kdf = unlockData.Kdf.KdfType; + user.KdfIterations = unlockData.Kdf.Iterations; + user.KdfMemory = unlockData.Kdf.Memory; + user.KdfParallelism = unlockData.Kdf.Parallelism; + + var now = DateTime.UtcNow; + user.RevisionDate = user.AccountRevisionDate = now; + user.LastKdfChangeDate = now; + + await _userRepository.ReplaceAsync(user); + await _pushService.PushLogOutAsync(user.Id); + return IdentityResult.Success; + } +} diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index 102630c7e6..e4ebdb4860 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using Bit.Core.KeyManagement.Commands; using Bit.Core.KeyManagement.Commands.Interfaces; +using Bit.Core.KeyManagement.Kdf; +using Bit.Core.KeyManagement.Kdf.Implementations; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.KeyManagement; @@ -9,10 +11,12 @@ public static class KeyManagementServiceCollectionExtensions public static void AddKeyManagementServices(this IServiceCollection services) { services.AddKeyManagementCommands(); + services.AddSendPasswordServices(); } private static void AddKeyManagementCommands(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/KeyManagement/Models/Data/KdfSettings.cs b/src/Core/KeyManagement/Models/Data/KdfSettings.cs new file mode 100644 index 0000000000..cc1e465330 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/KdfSettings.cs @@ -0,0 +1,38 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class KdfSettings +{ + public required KdfType KdfType { get; init; } + public required int Iterations { get; init; } + public int? Memory { get; init; } + public int? Parallelism { get; init; } + + public void ValidateUnchangedForUser(User user) + { + if (user.Kdf != KdfType || user.KdfIterations != Iterations || user.KdfMemory != Memory || user.KdfParallelism != Parallelism) + { + throw new ArgumentException("Invalid KDF settings."); + } + } + + public override bool Equals(object? obj) + { + if (obj is not KdfSettings other) + { + return false; + } + + return KdfType == other.KdfType && + Iterations == other.Iterations && + Memory == other.Memory && + Parallelism == other.Parallelism; + } + + public override int GetHashCode() + { + return HashCode.Combine(KdfType, Iterations, Memory, Parallelism); + } +} diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs new file mode 100644 index 0000000000..c0ae949a3f --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs @@ -0,0 +1,18 @@ +using Bit.Core.Entities; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class MasterPasswordAuthenticationData +{ + public required KdfSettings Kdf { get; init; } + public required string MasterPasswordAuthenticationHash { get; init; } + public required string Salt { get; init; } + + public void ValidateSaltUnchangedForUser(User user) + { + if (user.GetMasterPasswordSalt() != Salt) + { + throw new ArgumentException("Invalid master password salt."); + } + } +} diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs new file mode 100644 index 0000000000..e305d92fec --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs @@ -0,0 +1,34 @@ +#nullable enable +using Bit.Core.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class MasterPasswordUnlockAndAuthenticationData +{ + public KdfType KdfType { get; set; } + public int KdfIterations { get; set; } + public int? KdfMemory { get; set; } + public int? KdfParallelism { get; set; } + + public required string Email { get; set; } + public required string MasterKeyAuthenticationHash { get; set; } + public required string MasterKeyEncryptedUserKey { get; set; } + public string? MasterPasswordHint { get; set; } + + public bool ValidateForUser(User user) + { + if (KdfType != user.Kdf || KdfMemory != user.KdfMemory || KdfParallelism != user.KdfParallelism || KdfIterations != user.KdfIterations) + { + return false; + } + else if (Email != user.Email) + { + return false; + } + else + { + return true; + } + } +} diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs index 0ddfc03190..d1ab6f645b 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs @@ -1,34 +1,20 @@ #nullable enable + using Bit.Core.Entities; -using Bit.Core.Enums; namespace Bit.Core.KeyManagement.Models.Data; public class MasterPasswordUnlockData { - public KdfType KdfType { get; set; } - public int KdfIterations { get; set; } - public int? KdfMemory { get; set; } - public int? KdfParallelism { get; set; } + public required KdfSettings Kdf { get; init; } + public required string MasterKeyWrappedUserKey { get; init; } + public required string Salt { get; init; } - public required string Email { get; set; } - public required string MasterKeyAuthenticationHash { get; set; } - public required string MasterKeyEncryptedUserKey { get; set; } - public string? MasterPasswordHint { get; set; } - - public bool ValidateForUser(User user) + public void ValidateSaltUnchangedForUser(User user) { - if (KdfType != user.Kdf || KdfMemory != user.KdfMemory || KdfParallelism != user.KdfParallelism || KdfIterations != user.KdfIterations) + if (user.GetMasterPasswordSalt() != Salt) { - return false; - } - else if (Email != user.Email) - { - return false; - } - else - { - return true; + throw new ArgumentException("Invalid master password salt."); } } } diff --git a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs index f81baf6fab..557fb56ff3 100644 --- a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs +++ b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; using Bit.Core.Tools.Entities; @@ -16,7 +19,7 @@ public class RotateUserAccountKeysData public string AccountPublicKey { get; set; } // All methods to get to the userkey - public MasterPasswordUnlockData MasterPasswordUnlockData { get; set; } + public MasterPasswordUnlockAndAuthenticationData MasterPasswordUnlockData { get; set; } public IEnumerable EmergencyAccesses { get; set; } public IReadOnlyList OrganizationUsers { get; set; } public IEnumerable WebAuthnKeys { get; set; } diff --git a/src/Core/KeyManagement/Models/Response/MasterPasswordUnlockResponseModel.cs b/src/Core/KeyManagement/Models/Response/MasterPasswordUnlockResponseModel.cs new file mode 100644 index 0000000000..f7d5dee852 --- /dev/null +++ b/src/Core/KeyManagement/Models/Response/MasterPasswordUnlockResponseModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.KeyManagement.Models.Response; + +public class MasterPasswordUnlockResponseModel +{ + public required MasterPasswordUnlockKdfResponseModel Kdf { get; init; } + [EncryptedString] public required string MasterKeyEncryptedUserKey { get; init; } + [StringLength(256)] public required string Salt { get; init; } +} + +public class MasterPasswordUnlockKdfResponseModel +{ + public required KdfType KdfType { get; init; } + public required int Iterations { get; init; } + public int? Memory { get; init; } + public int? Parallelism { get; init; } +} diff --git a/src/Core/KeyManagement/Models/Response/UserDecryptionResponseModel.cs b/src/Core/KeyManagement/Models/Response/UserDecryptionResponseModel.cs new file mode 100644 index 0000000000..a4d259a00a --- /dev/null +++ b/src/Core/KeyManagement/Models/Response/UserDecryptionResponseModel.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.KeyManagement.Models.Response; + +public class UserDecryptionResponseModel +{ + /// + /// Returns the unlock data when the user has a master password that can be used to decrypt their vault. + /// + public MasterPasswordUnlockResponseModel? MasterPasswordUnlock { get; set; } +} diff --git a/src/Core/KeyManagement/Sends/ISendPasswordHasher.cs b/src/Core/KeyManagement/Sends/ISendPasswordHasher.cs new file mode 100644 index 0000000000..b84be5abc0 --- /dev/null +++ b/src/Core/KeyManagement/Sends/ISendPasswordHasher.cs @@ -0,0 +1,20 @@ +namespace Bit.Core.KeyManagement.Sends; + +public interface ISendPasswordHasher +{ + /// + /// Matches the send password hash against the user provided client password hash. The send password is server hashed and the client + /// password hash is hashed by the server for comparison in this method. + /// + /// The send password that is hashed by the server. + /// The user provided password hash that has not yet been hashed by the server for comparison. + /// true if hashes match false otherwise + bool PasswordHashMatches(string sendPasswordHash, string clientPasswordHash); + + /// + /// Accepts a client hashed send password and returns a server hashed password. + /// + /// + /// server hashed password + string HashOfClientPasswordHash(string clientHashedPassword); +} diff --git a/src/Core/KeyManagement/Sends/SendPasswordHasher.cs b/src/Core/KeyManagement/Sends/SendPasswordHasher.cs new file mode 100644 index 0000000000..abe57d3cc6 --- /dev/null +++ b/src/Core/KeyManagement/Sends/SendPasswordHasher.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.Sends; + +internal class SendPasswordHasher(IPasswordHasher passwordHasher) : ISendPasswordHasher +{ + private readonly IPasswordHasher _passwordHasher = passwordHasher; + + /// + /// + /// + public bool PasswordHashMatches(string sendPasswordHash, string inputPasswordHash) + { + if (string.IsNullOrWhiteSpace(sendPasswordHash) || string.IsNullOrWhiteSpace(inputPasswordHash)) + { + return false; + } + + var passwordResult = _passwordHasher.VerifyHashedPassword(SendPasswordHasherMarker.Instance, sendPasswordHash, inputPasswordHash); + + /* + In our use-case we input a high-entropy, pre-hashed secret sent by the client. Thus, we don't really care + about if the hash needs to be rehashed. Sends also only live for 30 days max. + */ + return passwordResult is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded; + } + + /// + /// + /// + public string HashOfClientPasswordHash(string clientHashedPassword) + { + return _passwordHasher.HashPassword(SendPasswordHasherMarker.Instance, clientHashedPassword); + } +} diff --git a/src/Core/KeyManagement/Sends/SendPasswordHasherMarker.cs b/src/Core/KeyManagement/Sends/SendPasswordHasherMarker.cs new file mode 100644 index 0000000000..d4b80a09a2 --- /dev/null +++ b/src/Core/KeyManagement/Sends/SendPasswordHasherMarker.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.KeyManagement.Sends; + +// This should not be used except for DI as open generic marker class for use with +// the SendPasswordHasher. +public class SendPasswordHasherMarker +{ + // We know we will pass a single instance that isn't used to the PasswordHasher so we + // gain an efficiency benefit of not creating multiple marker classes. + public static readonly SendPasswordHasherMarker Instance = new(); +} diff --git a/src/Core/KeyManagement/Sends/SendPasswordHasherServiceCollectionExtensions.cs b/src/Core/KeyManagement/Sends/SendPasswordHasherServiceCollectionExtensions.cs new file mode 100644 index 0000000000..22939ce60c --- /dev/null +++ b/src/Core/KeyManagement/Sends/SendPasswordHasherServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using Bit.Core.Auth.UserFeatures.PasswordValidation; +using Bit.Core.KeyManagement.Sends; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +using Microsoft.Extensions.Options; + +public static class SendPasswordHasherServiceCollectionExtensions +{ + public static void AddSendPasswordServices(this IServiceCollection services) + { + const string sendPasswordHasherMarkerName = "SendPasswordHasherMarker"; + + services.AddOptions(sendPasswordHasherMarkerName) + .Configure(options => options.IterationCount = PasswordValidationConstants.PasswordHasherKdfIterations); + + services.TryAddScoped>(sp => + { + var opts = sp + .GetRequiredService>() + .Get(sendPasswordHasherMarkerName); + + var optionsAccessor = Options.Create(opts); + + return new PasswordHasher(optionsAccessor); + }); + services.TryAddScoped(); + } +} diff --git a/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs b/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs index 1550f3c186..c8bd7cab1f 100644 --- a/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs +++ b/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.KeyManagement.Models.Data; using Microsoft.AspNetCore.Identity; using Microsoft.Data.SqlClient; diff --git a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs index 6967c9bf85..91363abee8 100644 --- a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs +++ b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs @@ -45,7 +45,8 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository, IDeviceRepository deviceRepository, IPasswordHasher passwordHasher, - IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository) + IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository, + IFeatureService featureService) { _userService = userService; _userRepository = userRepository; diff --git a/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.html.hbs new file mode 100644 index 0000000000..56052c7a0d --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.html.hbs @@ -0,0 +1,37 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + + + + + +
+ We've detected a failed login attempt +
+
+ If you're having trouble with two-step login, please visit the Help Center. +
+
+ If you did not recently try to log in, open the web app and take these immediate steps to secure your Bitwarden account: +
    +
  • Deauthorize all devices
  • +
  • Change your master password
  • +
+
+
+
+
+ Account: {{AffectedEmail}}
+ Two-Step Login Method: {{TwoFactorType}}
+ Date: {{TheDate}} at {{TheTime}} {{TimeZone}}
+ IP Address: {{IpAddress}}
+
+{{/FullHtmlLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.text.hbs new file mode 100644 index 0000000000..4ad5dd32a3 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.text.hbs @@ -0,0 +1,18 @@ +{{#>BasicTextLayout}} +We've detected a failed login attempt + +If you're having trouble with two-step login, please visit the Help Center (https://bitwarden.com/help/). + +If you did not recently try to log in, open the web app ({{{WebVaultUrl}}}) and take these immediate steps to secure your Bitwarden account: +- Deauthorize all devices +- Change your master password + +Account: {{AffectedEmail}} +Two-Step Login Method: {{TwoFactorType}} +Date: {{TheDate}} at {{TheTime}} {{TimeZone}} +IP Address: {{IpAddress}} + +{{/BasicTextLayout}} + + + diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs new file mode 100644 index 0000000000..5bf1f24218 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs @@ -0,0 +1,28 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + + + + +
+ Verify your email to access this Bitwarden Send. +
+
+ Your verification code is: {{Token}} +
+
+ This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again. +
+
+
+ {{TheDate}} at {{TheTime}} {{TimeZone}} +
+{{/FullHtmlLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs new file mode 100644 index 0000000000..f83008c30b --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs @@ -0,0 +1,9 @@ +{{#>BasicTextLayout}} +Verify your email to access this Bitwarden Send. + +Your verification code is: {{Token}} + +This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again. + +Date : {{TheDate}} at {{TheTime}} {{TimeZone}} +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.html.hbs index 27a222f1de..7add179787 100644 --- a/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.html.hbs @@ -12,7 +12,9 @@
  • Deauthorize unrecognized devices
  • Change your master password
  • -
  • Turn on two-step login
  • + {{#if DisplayTwoFactorReminder}} +
  • Turn on two-step login
  • + {{/if}}
diff --git a/src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs b/src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs new file mode 100644 index 0000000000..33e32c2bb0 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs @@ -0,0 +1,211 @@ + + + + + + Bitwarden + + + + + {{! Yahoo center fix }} + + + + +
+ {{! 600px container }} + + + {{! Left column (center fix) }} + + {{! Right column (center fix) }} + +
+ + + + + +
+ Bitwarden +
+ + + + + + +
+ + {{>@partial-block}} + +
+ + + + + + + + + + + +
+
+ + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs index 1f4300c23e..efeab22b9b 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationSmServiceAccountsMaxReached.html.hbs @@ -6,7 +6,7 @@ - Your organization has reached the Secrets Manager machine accounts limit of {{MaxServiceAccountsCount}}. New machine accounts cannot be created + Your organization has reached the Secrets Manager machine accounts limit of {{MaxServiceAccountsCount}}. New machine accounts cannot be created. BasicTextLayout}} -Your organization has reached the Secrets Manager machine accounts limit of {{MaxServiceAccountsCount}}. New machine accounts cannot be created +Your organization has reached the Secrets Manager machine accounts limit of {{MaxServiceAccountsCount}}. New machine accounts cannot be created. For more information, please refer to the following help article: https://bitwarden.com/help/managing-users {{/BasicTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/OrganizationUserInvited.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationUserInvited.html.hbs index 33c3a9256d..f2594a4c12 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationUserInvited.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationUserInvited.html.hbs @@ -3,7 +3,7 @@ - Join Organization Now + {{JoinOrganizationButtonText}} diff --git a/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.html.hbs b/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.html.hbs new file mode 100644 index 0000000000..d9061d1ffe --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.html.hbs @@ -0,0 +1,89 @@ +{{#>ProviderFull}} + + + + + {{#unless (eq CollectionMethod "send_invoice")}} + + + + {{/unless}} + {{#if Items}} + {{#unless (eq CollectionMethod "send_invoice")}} + + + + {{/unless}} + {{/if}} + + + + {{#unless (eq CollectionMethod "send_invoice")}} + + + + + {{/unless}} + + + + {{#if (eq CollectionMethod "send_invoice")}} + + + + {{/if}} + {{#unless (eq CollectionMethod "send_invoice")}} + + + + {{/unless}} +
+ {{#if (eq CollectionMethod "send_invoice")}} +
Your subscription will renew soon
+
On {{date DueDate 'MMMM dd, yyyy'}} we'll send you an invoice with a summary of the charges including tax.
+ {{else}} +
Your subscription will renew on {{date DueDate 'MMMM dd, yyyy'}}
+ {{#if HasPaymentMethod}} +
To avoid any interruption in service, please ensure your {{PaymentMethodDescription}} can be charged for the following amount:
+ {{else}} +
To avoid any interruption in service, please add a payment method that can be charged for the following amount:
+ {{/if}} + {{/if}} +
+ {{usd AmountDue}} +
+ Summary Of Charges
+
+ {{#each Items}} +
{{this}}
+ {{/each}} +
+ {{#if (eq CollectionMethod "send_invoice")}} +
To avoid any interruption in service for you or your clients, please pay the invoice by the due date, or contact Bitwarden Customer Support to sign up for auto-pay.
+ {{else}} + + {{/if}} +
+ + + + +
+ Update payment method +
+
+ {{#if (eq CollectionMethod "send_invoice")}} + + + + +
+ Contact Bitwarden Support +
+ {{/if}} +
+ For assistance managing your subscription, please visit the Help Center or contact Bitwarden Customer Support. +
+ For assistance managing your subscription, please visit the Help Center or contact Bitwarden Customer Support. +
+{{/ProviderFull}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.text.hbs b/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.text.hbs new file mode 100644 index 0000000000..c666e287a5 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.text.hbs @@ -0,0 +1,41 @@ +{{#>BasicTextLayout}} +{{#if (eq CollectionMethod "send_invoice")}} +Your subscription will renew soon + +On {{date DueDate 'MMMM dd, yyyy'}} we'll send you an invoice with a summary of the charges including tax. +{{else}} +Your subscription will renew on {{date DueDate 'MMMM dd, yyyy'}} + + {{#if HasPaymentMethod}} +To avoid any interruption in service, please ensure your {{PaymentMethodDescription}} can be charged for the following amount: + {{else}} +To avoid any interruption in service, please add a payment method that can be charged for the following amount: + {{/if}} + +{{usd AmountDue}} +{{/if}} +{{#if Items}} +{{#unless (eq CollectionMethod "send_invoice")}} + +Summary Of Charges +------------------ +{{#each Items}} +{{this}} +{{/each}} +{{/unless}} +{{/if}} + +{{#if (eq CollectionMethod "send_invoice")}} +To avoid any interruption in service for you or your clients, please pay the invoice by the due date, or contact Bitwarden Customer Support to sign up for auto-pay. + +Contact Bitwarden Support: {{{ContactUrl}}} + +For assistance managing your subscription, please visit the **Help center** (https://bitwarden.com/help/update-billing-info) or **contact Bitwarden Customer Support** (https://bitwarden.com/contact/). +{{else}} + +{{/if}} + +{{#unless (eq CollectionMethod "send_invoice")}} +For assistance managing your subscription, please visit the **Help center** (https://bitwarden.com/help/update-billing-info) or **contact Bitwarden Customer Support** (https://bitwarden.com/contact/). +{{/unless}} +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs index 79c3893785..a27575b959 100644 --- a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs +++ b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.html.hbs @@ -3,8 +3,8 @@ - Keep you and your organization's data safe by changing passwords that are weak, reused, or have been exposed in a - data breach. + Keep yourself and your organization's data safe by changing passwords that are weak, reused, or have been exposed + in a data breach. diff --git a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs index f6c0921165..8e10afc897 100644 --- a/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs +++ b/src/Core/MailTemplates/Handlebars/SecurityTasksNotification.text.hbs @@ -1,6 +1,6 @@ {{#>SecurityTasksHtmlLayout}} -Keep you and your organization's data safe by changing passwords that are weak, reused, or have been exposed in a data -breach. +Keep yourself and your organization's data safe by changing passwords that are weak, reused, or have been exposed in a +data breach. Launch the Bitwarden extension to review your at-risk passwords. diff --git a/src/Core/MailTemplates/Mjml/.mjmlconfig b/src/Core/MailTemplates/Mjml/.mjmlconfig new file mode 100644 index 0000000000..7560e0fb96 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/.mjmlconfig @@ -0,0 +1,5 @@ +{ + "packages": [ + "components/hero" + ] +} diff --git a/src/Core/MailTemplates/Mjml/README.md b/src/Core/MailTemplates/Mjml/README.md new file mode 100644 index 0000000000..b60655140a --- /dev/null +++ b/src/Core/MailTemplates/Mjml/README.md @@ -0,0 +1,19 @@ +# Email templates + +This directory contains MJML templates for emails sent by the application. MJML is a markup language designed to reduce the pain of coding responsive email templates. + +## Usage + +```bash +npm ci + +# Build once +npm run build + +# To build on changes +npm run watch +``` + +## Development + +MJML supports components and you can create your own components by adding them to `.mjmlconfig`. diff --git a/src/Core/MailTemplates/Mjml/build.sh b/src/Core/MailTemplates/Mjml/build.sh new file mode 100755 index 0000000000..c76bdd8f61 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/build.sh @@ -0,0 +1,4 @@ +# TODO: This should probably be replaced with a node script building every file in `emails/` + +npx mjml emails/invite.mjml -o out/invite.html +npx mjml emails/two-factor.mjml -o out/two-factor.html diff --git a/src/Core/MailTemplates/Mjml/components/footer.mjml b/src/Core/MailTemplates/Mjml/components/footer.mjml new file mode 100644 index 0000000000..0634033618 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/footer.mjml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+
+
+
diff --git a/src/Core/MailTemplates/Mjml/components/head.mjml b/src/Core/MailTemplates/Mjml/components/head.mjml new file mode 100644 index 0000000000..929057fb70 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/head.mjml @@ -0,0 +1,16 @@ + + + + + + + + .link { text-decoration: none; color: #175ddc; font-weight: 600 } + + + .border-fix > table { border-collapse:separate !important; } .border-fix > + table > tbody > tr > td { border-radius: 3px; } + diff --git a/src/Core/MailTemplates/Mjml/components/hero.js b/src/Core/MailTemplates/Mjml/components/hero.js new file mode 100644 index 0000000000..6c5bd9bc99 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/hero.js @@ -0,0 +1,64 @@ +const { BodyComponent } = require("mjml-core"); +class MjBwHero extends BodyComponent { + static dependencies = { + // Tell the validator which tags are allowed as our component's parent + "mj-column": ["mj-bw-hero"], + "mj-wrapper": ["mj-bw-hero"], + // Tell the validator which tags are allowed as our component's children + "mj-bw-hero": [], + }; + + static allowedAttributes = { + "img-src": "string", + title: "string", + "button-text": "string", + "button-url": "string", + }; + + static defaultAttributes = {}; + + render() { + return this.renderMJML(` + + + + +

+ ${this.getAttribute("title")} +

+
+ + ${this.getAttribute("button-text")} + +
+ + + +
+ `); + } +} + +module.exports = MjBwHero; diff --git a/src/Core/MailTemplates/Mjml/components/logo.mjml b/src/Core/MailTemplates/Mjml/components/logo.mjml new file mode 100644 index 0000000000..b8e46a1137 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/logo.mjml @@ -0,0 +1,11 @@ + + + + + diff --git a/src/Core/MailTemplates/Mjml/emails/invite.mjml b/src/Core/MailTemplates/Mjml/emails/invite.mjml new file mode 100644 index 0000000000..4eae12d0dc --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/invite.mjml @@ -0,0 +1,49 @@ + + + + + + + + + + + + Join Organization Now + + + This invitation expires on + Tuesday, January 23, 2024 2:59PM UTC. + + + + + + +

+ We’re here for you! +

+ If you have any questions, search the Bitwarden + Help + site or + contact us. +
+
+ + + +
+
+ + +
+
diff --git a/src/Core/MailTemplates/Mjml/emails/two-factor.mjml b/src/Core/MailTemplates/Mjml/emails/two-factor.mjml new file mode 100644 index 0000000000..b959ec1c8a --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/two-factor.mjml @@ -0,0 +1,27 @@ + + + + + + + + + + + + +

Your two-step verification code is: {{Token}}

+

Use this code to complete logging in with Bitwarden.

+
+
+
+
+ + +
+
diff --git a/src/Core/MailTemplates/Mjml/package-lock.json b/src/Core/MailTemplates/Mjml/package-lock.json new file mode 100644 index 0000000000..df85185af9 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/package-lock.json @@ -0,0 +1,2186 @@ +{ + "name": "@bitwarden/mjml-emails", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@bitwarden/mjml-emails", + "version": "1.0.0", + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "mjml": "4.15.3", + "mjml-core": "4.15.3" + }, + "devDependencies": { + "nodemon": "3.1.10", + "prettier": "3.6.2" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camel-case": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-3.0.0.tgz", + "integrity": "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==", + "license": "MIT", + "dependencies": { + "no-case": "^2.2.0", + "upper-case": "^1.1.1" + } + }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/clean-css": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz", + "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==", + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-goat": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", + "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-minifier": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-4.0.0.tgz", + "integrity": "sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==", + "license": "MIT", + "dependencies": { + "camel-case": "^3.0.0", + "clean-css": "^4.2.1", + "commander": "^2.19.0", + "he": "^1.2.0", + "param-case": "^2.1.1", + "relateurl": "^0.2.7", + "uglify-js": "^3.5.1" + }, + "bin": { + "html-minifier": "cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/juice": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/juice/-/juice-10.0.1.tgz", + "integrity": "sha512-ZhJT1soxJCkOiO55/mz8yeBKTAJhRzX9WBO+16ZTqNTONnnVlUPyVBIzQ7lDRjaBdTbid+bAnyIon/GM3yp4cA==", + "license": "MIT", + "dependencies": { + "cheerio": "1.0.0-rc.12", + "commander": "^6.1.0", + "mensch": "^0.3.4", + "slick": "^1.12.2", + "web-resource-inliner": "^6.0.1" + }, + "bin": { + "juice": "bin/juice" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/juice/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz", + "integrity": "sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/mensch": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz", + "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==", + "license": "MIT" + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mjml": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml/-/mjml-4.15.3.tgz", + "integrity": "sha512-bW2WpJxm6HS+S3Yu6tq1DUPFoTxU9sPviUSmnL7Ua+oVO3WA5ILFWqvujUlz+oeuM+HCwEyMiP5xvKNPENVjYA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "mjml-cli": "4.15.3", + "mjml-core": "4.15.3", + "mjml-migrate": "4.15.3", + "mjml-preset-core": "4.15.3", + "mjml-validator": "4.15.3" + }, + "bin": { + "mjml": "bin/mjml" + } + }, + "node_modules/mjml-accordion": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-accordion/-/mjml-accordion-4.15.3.tgz", + "integrity": "sha512-LPNVSj1LyUVYT9G1gWwSw3GSuDzDsQCu0tPB2uDsq4VesYNnU6v3iLCQidMiR6azmIt13OEozG700ygAUuA6Ng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-body": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-body/-/mjml-body-4.15.3.tgz", + "integrity": "sha512-7pfUOVPtmb0wC+oUOn4xBsAw4eT5DyD6xqaxj/kssu6RrFXOXgJaVnDPAI9AzIvXJ/5as9QrqRGYAddehwWpHQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-button": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-button/-/mjml-button-4.15.3.tgz", + "integrity": "sha512-79qwn9AgdGjJR1vLnrcm2rq2AsAZkKC5JPwffTMG+Nja6zGYpTDZFZ56ekHWr/r1b5WxkukcPj2PdevUug8c+Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-carousel": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-carousel/-/mjml-carousel-4.15.3.tgz", + "integrity": "sha512-3ju6I4l7uUhPRrJfN3yK9AMsfHvrYbRkcJ1GRphFHzUj37B2J6qJOQUpzA547Y4aeh69TSb7HFVf1t12ejQxVw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-cli": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-cli/-/mjml-cli-4.15.3.tgz", + "integrity": "sha512-+V2TDw3tXUVEptFvLSerz125C2ogYl8klIBRY1m5BHd4JvGVf3yhx8N3PngByCzA6PGcv/eydGQN+wy34SHf0Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "chokidar": "^3.0.0", + "glob": "^10.3.10", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "minimatch": "^9.0.3", + "mjml-core": "4.15.3", + "mjml-migrate": "4.15.3", + "mjml-parser-xml": "4.15.3", + "mjml-validator": "4.15.3", + "yargs": "^17.7.2" + }, + "bin": { + "mjml-cli": "bin/mjml" + } + }, + "node_modules/mjml-column": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-4.15.3.tgz", + "integrity": "sha512-hYdEFdJGHPbZJSEysykrevEbB07yhJGSwfDZEYDSbhQQFjV2tXrEgYcFD5EneMaowjb55e3divSJxU4c5q4Qgw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-core": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.15.3.tgz", + "integrity": "sha512-Dmwk+2cgSD9L9GmTbEUNd8QxkTZtW9P7FN/ROZW/fGZD6Hq6/4TB0zEspg2Ow9eYjZXO2ofOJ3PaQEEShKV0kQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.15.3", + "mjml-parser-xml": "4.15.3", + "mjml-validator": "4.15.3" + } + }, + "node_modules/mjml-divider": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-divider/-/mjml-divider-4.15.3.tgz", + "integrity": "sha512-vh27LQ9FG/01y0b9ntfqm+GT5AjJnDSDY9hilss2ixIUh0FemvfGRfsGVeV5UBVPBKK7Ffhvfqc7Rciob9Spzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-group": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-group/-/mjml-group-4.15.3.tgz", + "integrity": "sha512-HSu/rKnGZVKFq3ciT46vi1EOy+9mkB0HewO4+P6dP/Y0UerWkN6S3UK11Cxsj0cAp0vFwkPDCdOeEzRdpFEkzA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head/-/mjml-head-4.15.3.tgz", + "integrity": "sha512-o3mRuuP/MB5fZycjD3KH/uXsnaPl7Oo8GtdbJTKtH1+O/3pz8GzGMkscTKa97l03DAG2EhGrzzLcU2A6eshwFw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-attributes": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-attributes/-/mjml-head-attributes-4.15.3.tgz", + "integrity": "sha512-2ISo0r5ZKwkrvJgDou9xVPxxtXMaETe2AsAA02L89LnbB2KC0N5myNsHV0sEysTw9+CfCmgjAb0GAI5QGpxKkQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-breakpoint": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-breakpoint/-/mjml-head-breakpoint-4.15.3.tgz", + "integrity": "sha512-Eo56FA5C2v6ucmWQL/JBJ2z641pLOom4k0wP6CMZI2utfyiJ+e2Uuinj1KTrgDcEvW4EtU9HrfAqLK9UosLZlg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-font": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-font/-/mjml-head-font-4.15.3.tgz", + "integrity": "sha512-CzV2aDPpiNIIgGPHNcBhgyedKY4SX3BJoTwOobSwZVIlEA6TAWB4Z9WwFUmQqZOgo1AkkiTHPZQvGcEhFFXH6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-html-attributes": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-html-attributes/-/mjml-head-html-attributes-4.15.3.tgz", + "integrity": "sha512-MDNDPMBOgXUZYdxhosyrA2kudiGO8aogT0/cODyi2Ed9o/1S7W+je11JUYskQbncqhWKGxNyaP4VWa+6+vUC/g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-preview": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-preview/-/mjml-head-preview-4.15.3.tgz", + "integrity": "sha512-J2PxCefUVeFwsAExhrKo4lwxDevc5aKj888HBl/wN4EuWOoOg06iOGCxz4Omd8dqyFsrqvbBuPqRzQ+VycGmaA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-style": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-style/-/mjml-head-style-4.15.3.tgz", + "integrity": "sha512-9J+JuH+mKrQU65CaJ4KZegACUgNIlYmWQYx3VOBR/tyz+8kDYX7xBhKJCjQ1I4wj2Tvga3bykd89Oc2kFZ5WOw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-head-title": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-title/-/mjml-head-title-4.15.3.tgz", + "integrity": "sha512-IM59xRtsxID4DubQ0iLmoCGXguEe+9BFG4z6y2xQDrscIa4QY3KlfqgKGT69ojW+AVbXXJPEVqrAi4/eCsLItQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-hero": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-hero/-/mjml-hero-4.15.3.tgz", + "integrity": "sha512-9cLAPuc69yiuzNrMZIN58j+HMK1UWPaq2i3/Fg2ZpimfcGFKRcPGCbEVh0v+Pb6/J0+kf8yIO0leH20opu3AyQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-image": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-image/-/mjml-image-4.15.3.tgz", + "integrity": "sha512-g1OhSdofIytE9qaOGdTPmRIp7JsCtgO0zbsn1Fk6wQh2gEL55Z40j/VoghslWAWTgT2OHFdBKnMvWtN6U5+d2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-migrate": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.15.3.tgz", + "integrity": "sha512-sr/+35RdxZroNQVegjpfRHJ5hda9XCgaS4mK2FGO+Mb1IUevKfeEPII3F/cHDpNwFeYH3kAgyqQ22ClhGLWNBA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.15.3", + "mjml-parser-xml": "4.15.3", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-navbar": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-navbar/-/mjml-navbar-4.15.3.tgz", + "integrity": "sha512-VsKH/Jdlf8Yu3y7GpzQV5n7JMdpqvZvTSpF6UQXL0PWOm7k6+LX+sCZimOfpHJ+wCaaybpxokjWZ71mxOoCWoA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-parser-xml": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.15.3.tgz", + "integrity": "sha512-Tz0UX8/JVYICLjT+U8J1f/TFxIYVYjzZHeh4/Oyta0pLpRLeZlxEd71f3u3kdnulCKMP4i37pFRDmyLXAlEuLw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.15" + } + }, + "node_modules/mjml-parser-xml/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-preset-core": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-preset-core/-/mjml-preset-core-4.15.3.tgz", + "integrity": "sha512-1zZS8P4O0KweWUqNS655+oNnVMPQ1Rq1GaZq5S9JfwT1Vh/m516lSmiTW9oko6gGHytt5s6Yj6oOeu5Zm8FoLw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "mjml-accordion": "4.15.3", + "mjml-body": "4.15.3", + "mjml-button": "4.15.3", + "mjml-carousel": "4.15.3", + "mjml-column": "4.15.3", + "mjml-divider": "4.15.3", + "mjml-group": "4.15.3", + "mjml-head": "4.15.3", + "mjml-head-attributes": "4.15.3", + "mjml-head-breakpoint": "4.15.3", + "mjml-head-font": "4.15.3", + "mjml-head-html-attributes": "4.15.3", + "mjml-head-preview": "4.15.3", + "mjml-head-style": "4.15.3", + "mjml-head-title": "4.15.3", + "mjml-hero": "4.15.3", + "mjml-image": "4.15.3", + "mjml-navbar": "4.15.3", + "mjml-raw": "4.15.3", + "mjml-section": "4.15.3", + "mjml-social": "4.15.3", + "mjml-spacer": "4.15.3", + "mjml-table": "4.15.3", + "mjml-text": "4.15.3", + "mjml-wrapper": "4.15.3" + } + }, + "node_modules/mjml-raw": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-raw/-/mjml-raw-4.15.3.tgz", + "integrity": "sha512-IGyHheOYyRchBLiAEgw3UM11kFNmBSMupu2BDdejC6ZiDhEAdG+tyERlsCwDPYtXanvFpGWULIu3XlsUPc+RZw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-section": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-section/-/mjml-section-4.15.3.tgz", + "integrity": "sha512-JfVPRXH++Hd933gmQfG8JXXCBCR6fIzC3DwiYycvanL/aW1cEQ2EnebUfQkt5QzlYjOkJEH+JpccAsq3ln6FZQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-social": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-social/-/mjml-social-4.15.3.tgz", + "integrity": "sha512-7sD5FXrESOxpT9Z4Oh36bS6u/geuUrMP1aCg2sjyAwbPcF1aWa2k9OcatQfpRf6pJEhUZ18y6/WBBXmMVmSzXg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-spacer": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-spacer/-/mjml-spacer-4.15.3.tgz", + "integrity": "sha512-3B7Qj+17EgDdAtZ3NAdMyOwLTX1jfmJuY7gjyhS2HtcZAmppW+cxqHUBwCKfvSRgTQiccmEvtNxaQK+tfyrZqA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-table": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-table/-/mjml-table-4.15.3.tgz", + "integrity": "sha512-FLx7DcRKTdKdcOCbMyBaeudeHaHpwPveRrBm6WyQe3LXx6FfdmOh59i71/16LFQMgBOD3N4/UJkzxLzlTJzMqQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-text": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-text/-/mjml-text-4.15.3.tgz", + "integrity": "sha512-+C0hxCmw9kg0XzT6vhE5mFkK6y225nC8UEQcN94K0fBCjPKkM+HqZMwGX205fzdGRi+Bxa55b/VhrIVwdv+8vw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3" + } + }, + "node_modules/mjml-validator": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.15.3.tgz", + "integrity": "sha512-Xb72KdqRwjv/qM2rJpV22syyP2N3cRQ9VVDrN6u2FSzLq02buFNxmSPJ7CKhat3PrUNdVHU75KZwOf/tz4UEhA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9" + } + }, + "node_modules/mjml-wrapper": { + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-wrapper/-/mjml-wrapper-4.15.3.tgz", + "integrity": "sha512-ditsCijeHJrmBmObtJmQ18ddLxv5oPyMTdPU8Di8APOnD2zPk7Z4UAuJSl7HXB45oFiivr3MJf4koFzMUSZ6Gg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "4.15.3", + "mjml-section": "4.15.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz", + "integrity": "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==", + "license": "MIT", + "dependencies": { + "lower-case": "^1.1.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/param-case": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz", + "integrity": "sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==", + "license": "MIT", + "dependencies": { + "no-case": "^2.2.0" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "license": "ISC" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slick": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz", + "integrity": "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==", + "license": "MIT (http://mootools.net/license.txt)", + "engines": { + "node": "*" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/upper-case": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz", + "integrity": "sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==", + "license": "MIT" + }, + "node_modules/valid-data-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz", + "integrity": "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/web-resource-inliner": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-6.0.1.tgz", + "integrity": "sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "escape-goat": "^3.0.0", + "htmlparser2": "^5.0.0", + "mime": "^2.4.6", + "node-fetch": "^2.6.0", + "valid-data-url": "^3.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/web-resource-inliner/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/dom-serializer/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domhandler": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz", + "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.0.1" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domutils/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/htmlparser2": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-5.0.1.tgz", + "integrity": "sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^3.3.0", + "domutils": "^2.4.2", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/fb55/htmlparser2?sponsor=1" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/src/Core/MailTemplates/Mjml/package.json b/src/Core/MailTemplates/Mjml/package.json new file mode 100644 index 0000000000..8a8f81e845 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/package.json @@ -0,0 +1,30 @@ +{ + "name": "@bitwarden/mjml-emails", + "version": "1.0.0", + "description": "Email templates for Bitwarden", + "private": true, + "type": "commonjs", + "repository": { + "type": "git", + "url": "git+https://github.com/bitwarden/server.git" + }, + "author": "Bitwarden Inc. (https://bitwarden.com)", + "license": "SEE LICENSE IN LICENSE.txt", + "bugs": { + "url": "https://github.com/bitwarden/server/issues" + }, + "homepage": "https://bitwarden.com", + "scripts": { + "build": "./build.sh", + "watch": "nodemon --exec ./build.sh --watch ./components --watch ./emails --ext js,mjml", + "prettier": "prettier --cache --write ." + }, + "dependencies": { + "mjml": "4.15.3", + "mjml-core": "4.15.3" + }, + "devDependencies": { + "nodemon": "3.1.10", + "prettier": "3.6.2" + } +} diff --git a/src/Core/Models/Api/Request/OrganizationLicenses/SelfHostedOrganizationLicenseRequestModel.cs b/src/Core/Models/Api/Request/OrganizationLicenses/SelfHostedOrganizationLicenseRequestModel.cs index 365d88877e..a010f828de 100644 --- a/src/Core/Models/Api/Request/OrganizationLicenses/SelfHostedOrganizationLicenseRequestModel.cs +++ b/src/Core/Models/Api/Request/OrganizationLicenses/SelfHostedOrganizationLicenseRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Api.OrganizationLicenses; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Api.OrganizationLicenses; public class SelfHostedOrganizationLicenseRequestModel { diff --git a/src/Core/Models/Api/Request/OrganizationSponsorships/OrganizationSponsorshipRequestModel.cs b/src/Core/Models/Api/Request/OrganizationSponsorships/OrganizationSponsorshipRequestModel.cs index 8be4a672db..c8d99c31f1 100644 --- a/src/Core/Models/Api/Request/OrganizationSponsorships/OrganizationSponsorshipRequestModel.cs +++ b/src/Core/Models/Api/Request/OrganizationSponsorships/OrganizationSponsorshipRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations.OrganizationSponsorships; diff --git a/src/Core/Models/Api/Request/OrganizationSponsorships/OrganizationSponsorshipSyncRequestModel.cs b/src/Core/Models/Api/Request/OrganizationSponsorships/OrganizationSponsorshipSyncRequestModel.cs index 283c07d199..f45c66ece8 100644 --- a/src/Core/Models/Api/Request/OrganizationSponsorships/OrganizationSponsorshipSyncRequestModel.cs +++ b/src/Core/Models/Api/Request/OrganizationSponsorships/OrganizationSponsorshipSyncRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Data.Organizations.OrganizationSponsorships; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Data.Organizations.OrganizationSponsorships; namespace Bit.Core.Models.Api.Request.OrganizationSponsorships; diff --git a/src/Core/Models/Api/Request/PushDeviceRequestModel.cs b/src/Core/Models/Api/Request/PushDeviceRequestModel.cs index 8b97dcc360..c8ef83cadb 100644 --- a/src/Core/Models/Api/Request/PushDeviceRequestModel.cs +++ b/src/Core/Models/Api/Request/PushDeviceRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Models.Api; diff --git a/src/Core/Models/Api/Request/PushRegistrationRequestModel.cs b/src/Core/Models/Api/Request/PushRegistrationRequestModel.cs index 0c87bf98d1..48afeacb21 100644 --- a/src/Core/Models/Api/Request/PushRegistrationRequestModel.cs +++ b/src/Core/Models/Api/Request/PushRegistrationRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Enums; namespace Bit.Core.Models.Api; diff --git a/src/Core/Models/Api/Request/PushUpdateRequestModel.cs b/src/Core/Models/Api/Request/PushUpdateRequestModel.cs index f8c2d296fd..e0a9696b38 100644 --- a/src/Core/Models/Api/Request/PushUpdateRequestModel.cs +++ b/src/Core/Models/Api/Request/PushUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Models.Api; diff --git a/src/Core/Models/Api/Response/Duo/DuoResponseModel.cs b/src/Core/Models/Api/Response/Duo/DuoResponseModel.cs index 573d77ab0c..bd59fa1921 100644 --- a/src/Core/Models/Api/Response/Duo/DuoResponseModel.cs +++ b/src/Core/Models/Api/Response/Duo/DuoResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Core.Models.Api.Response.Duo; diff --git a/src/Core/Models/Api/Response/ErrorResponseModel.cs b/src/Core/Models/Api/Response/ErrorResponseModel.cs index 39d6adddb1..57c8259179 100644 --- a/src/Core/Models/Api/Response/ErrorResponseModel.cs +++ b/src/Core/Models/Api/Response/ErrorResponseModel.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Mvc.ModelBinding; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Core.Models.Api; diff --git a/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipResponseModel.cs b/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipResponseModel.cs index e082d98de6..008637aa16 100644 --- a/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipResponseModel.cs +++ b/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations.OrganizationSponsorships; namespace Bit.Core.Models.Api.Response.OrganizationSponsorships; diff --git a/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipSyncResponseModel.cs b/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipSyncResponseModel.cs index 5a6b635c5a..ac95d8095b 100644 --- a/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipSyncResponseModel.cs +++ b/src/Core/Models/Api/Response/OrganizationSponsorships/OrganizationSponsorshipSyncResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Data.Organizations.OrganizationSponsorships; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Data.Organizations.OrganizationSponsorships; namespace Bit.Core.Models.Api.Response.OrganizationSponsorships; diff --git a/src/Core/Models/Business/CompleteSubscriptionUpdate.cs b/src/Core/Models/Business/CompleteSubscriptionUpdate.cs index 1d983404af..7473738ffc 100644 --- a/src/Core/Models/Business/CompleteSubscriptionUpdate.cs +++ b/src/Core/Models/Business/CompleteSubscriptionUpdate.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Exceptions; using Stripe; using Plan = Bit.Core.Models.StaticStore.Plan; diff --git a/src/Core/Models/Business/OrganizationSignup.cs b/src/Core/Models/Business/OrganizationSignup.cs index b8bd670d21..be79e71807 100644 --- a/src/Core/Models/Business/OrganizationSignup.cs +++ b/src/Core/Models/Business/OrganizationSignup.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Enums; namespace Bit.Core.Models.Business; diff --git a/src/Core/Models/Business/OrganizationUpgrade.cs b/src/Core/Models/Business/OrganizationUpgrade.cs index 1dd2650799..89b9a5e6f2 100644 --- a/src/Core/Models/Business/OrganizationUpgrade.cs +++ b/src/Core/Models/Business/OrganizationUpgrade.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Enums; namespace Bit.Core.Models.Business; diff --git a/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs b/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs index d85925db34..c813fd5b45 100644 --- a/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs +++ b/src/Core/Models/Business/SecretsManagerSubscriptionUpdate.cs @@ -50,11 +50,7 @@ public class SecretsManagerSubscriptionUpdate public bool MaxAutoscaleSmSeatsChanged => MaxAutoscaleSmSeats != Organization.MaxAutoscaleSmSeats; public bool MaxAutoscaleSmServiceAccountsChanged => MaxAutoscaleSmServiceAccounts != Organization.MaxAutoscaleSmServiceAccounts; - public bool SmSeatAutoscaleLimitReached => SmSeats.HasValue && MaxAutoscaleSmSeats.HasValue && SmSeats == MaxAutoscaleSmSeats; - public bool SmServiceAccountAutoscaleLimitReached => SmServiceAccounts.HasValue && - MaxAutoscaleSmServiceAccounts.HasValue && - SmServiceAccounts == MaxAutoscaleSmServiceAccounts; public SecretsManagerSubscriptionUpdate(Organization organization, Plan plan, bool autoscaling) { diff --git a/src/Core/Models/Business/SubscriptionInfo.cs b/src/Core/Models/Business/SubscriptionInfo.cs index 78a995fb94..a016ac54f3 100644 --- a/src/Core/Models/Business/SubscriptionInfo.cs +++ b/src/Core/Models/Business/SubscriptionInfo.cs @@ -1,4 +1,7 @@ -using Stripe; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Stripe; namespace Bit.Core.Models.Business; diff --git a/src/Core/Models/Business/SubscriptionUpdate.cs b/src/Core/Models/Business/SubscriptionUpdate.cs index f5afabfb9a..028fcad80b 100644 --- a/src/Core/Models/Business/SubscriptionUpdate.cs +++ b/src/Core/Models/Business/SubscriptionUpdate.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Enums; using Stripe; namespace Bit.Core.Models.Business; diff --git a/src/Core/Models/Business/TaxInfo.cs b/src/Core/Models/Business/TaxInfo.cs index 80a63473a7..4f95bb393d 100644 --- a/src/Core/Models/Business/TaxInfo.cs +++ b/src/Core/Models/Business/TaxInfo.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Business; public class TaxInfo { @@ -10,5 +13,5 @@ public class TaxInfo public string BillingAddressCity { get; set; } public string BillingAddressState { get; set; } public string BillingAddressPostalCode { get; set; } - public string BillingAddressCountry { get; set; } = "US"; + public string BillingAddressCountry { get; set; } = Constants.CountryAbbreviations.UnitedStates; } diff --git a/src/Core/Models/Business/Tokenables/OrganizationSponsorshipOfferTokenable.cs b/src/Core/Models/Business/Tokenables/OrganizationSponsorshipOfferTokenable.cs index 4bca8e1ca1..6c454154bb 100644 --- a/src/Core/Models/Business/Tokenables/OrganizationSponsorshipOfferTokenable.cs +++ b/src/Core/Models/Business/Tokenables/OrganizationSponsorshipOfferTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Tokens; diff --git a/src/Core/Models/Data/CollectionAccessDetails.cs b/src/Core/Models/Data/CollectionAccessDetails.cs index 447d55460c..5e294065e6 100644 --- a/src/Core/Models/Data/CollectionAccessDetails.cs +++ b/src/Core/Models/Data/CollectionAccessDetails.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Data; public class CollectionAccessDetails { diff --git a/src/Core/Models/Data/InstallationDeviceEntity.cs b/src/Core/Models/Data/InstallationDeviceEntity.cs index a3d960b242..cafc1d1c03 100644 --- a/src/Core/Models/Data/InstallationDeviceEntity.cs +++ b/src/Core/Models/Data/InstallationDeviceEntity.cs @@ -1,4 +1,7 @@ -using Azure; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Azure; using Azure.Data.Tables; namespace Bit.Core.Models.Data; diff --git a/src/Core/Models/Data/Organizations/OrganizationConnections/OrganizationConnectionData.cs b/src/Core/Models/Data/Organizations/OrganizationConnections/OrganizationConnectionData.cs index 7a9aa77110..dd7f04ac96 100644 --- a/src/Core/Models/Data/Organizations/OrganizationConnections/OrganizationConnectionData.cs +++ b/src/Core/Models/Data/Organizations/OrganizationConnections/OrganizationConnectionData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.OrganizationConnectionConfigs; diff --git a/src/Core/Models/Data/Organizations/OrganizationDomainSsoDetailsData.cs b/src/Core/Models/Data/Organizations/OrganizationDomainSsoDetailsData.cs index b188f9403a..31f82e19a6 100644 --- a/src/Core/Models/Data/Organizations/OrganizationDomainSsoDetailsData.cs +++ b/src/Core/Models/Data/Organizations/OrganizationDomainSsoDetailsData.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Data.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Data.Organizations; public class OrganizationDomainSsoDetailsData { diff --git a/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipData.cs b/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipData.cs index 649459bc6b..62fbd90975 100644 --- a/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipData.cs +++ b/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Enums; namespace Bit.Core.Models.Data.Organizations.OrganizationSponsorships; diff --git a/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipSyncData.cs b/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipSyncData.cs index 8c10187116..14562d54d9 100644 --- a/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipSyncData.cs +++ b/src/Core/Models/Data/Organizations/OrganizationSponsorships/OrganizationSponsorshipSyncData.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Data.Organizations.OrganizationSponsorships; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Data.Organizations.OrganizationSponsorships; public class OrganizationSponsorshipSyncData { diff --git a/src/Core/Models/Data/Organizations/VerifiedOrganizationDomainSsoDetail.cs b/src/Core/Models/Data/Organizations/VerifiedOrganizationDomainSsoDetail.cs index 0a07af66b8..ec1986962a 100644 --- a/src/Core/Models/Data/Organizations/VerifiedOrganizationDomainSsoDetail.cs +++ b/src/Core/Models/Data/Organizations/VerifiedOrganizationDomainSsoDetail.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Data.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Data.Organizations; public class VerifiedOrganizationDomainSsoDetail { diff --git a/src/Core/Models/Data/PageOptions.cs b/src/Core/Models/Data/PageOptions.cs index e9f12ece9a..16f049411a 100644 --- a/src/Core/Models/Data/PageOptions.cs +++ b/src/Core/Models/Data/PageOptions.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Data; public class PageOptions { diff --git a/src/Core/Models/Data/PagedResult.cs b/src/Core/Models/Data/PagedResult.cs index b02044dd8c..dc272727a5 100644 --- a/src/Core/Models/Data/PagedResult.cs +++ b/src/Core/Models/Data/PagedResult.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Data; public class PagedResult { diff --git a/src/Core/Models/IExternal.cs b/src/Core/Models/IExternal.cs index e81de1d47b..4ea613c0b3 100644 --- a/src/Core/Models/IExternal.cs +++ b/src/Core/Models/IExternal.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models; public interface IExternal { diff --git a/src/Core/Models/Mail/AdminResetPasswordViewModel.cs b/src/Core/Models/Mail/AdminResetPasswordViewModel.cs index 18e257fea7..8ff58e54a2 100644 --- a/src/Core/Models/Mail/AdminResetPasswordViewModel.cs +++ b/src/Core/Models/Mail/AdminResetPasswordViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class AdminResetPasswordViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs b/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs new file mode 100644 index 0000000000..5faf550e60 --- /dev/null +++ b/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs @@ -0,0 +1,12 @@ +namespace Bit.Core.Models.Mail.Auth; + +/// +/// Send email OTP view model +/// +public class DefaultEmailOtpViewModel : BaseMailModel +{ + public string? Token { get; set; } + public string? TheDate { get; set; } + public string? TheTime { get; set; } + public string? TimeZone { get; set; } +} diff --git a/src/Core/Models/Mail/BaseMailModel.cs b/src/Core/Models/Mail/BaseMailModel.cs index e3aa4d2c41..99873cf365 100644 --- a/src/Core/Models/Mail/BaseMailModel.cs +++ b/src/Core/Models/Mail/BaseMailModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class BaseMailModel { diff --git a/src/Core/Models/Mail/BaseTitleContactUsMailModel.cs b/src/Core/Models/Mail/BaseTitleContactUsMailModel.cs index a048312652..4fe42238e6 100644 --- a/src/Core/Models/Mail/BaseTitleContactUsMailModel.cs +++ b/src/Core/Models/Mail/BaseTitleContactUsMailModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class BaseTitleContactUsMailModel : BaseMailModel { diff --git a/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs b/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs index 328d37058b..ab0d2955e2 100644 --- a/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs +++ b/src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail.Billing; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail.Billing; public class BusinessUnitConversionInviteModel : BaseMailModel { diff --git a/src/Core/Models/Mail/ChangeEmailExistsViewModel.cs b/src/Core/Models/Mail/ChangeEmailExistsViewModel.cs index 22367e8f27..c872ba0bbb 100644 --- a/src/Core/Models/Mail/ChangeEmailExistsViewModel.cs +++ b/src/Core/Models/Mail/ChangeEmailExistsViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class ChangeEmailExistsViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/ClaimedDomainUserNotificationViewModel.cs b/src/Core/Models/Mail/ClaimedDomainUserNotificationViewModel.cs index 97591b51bc..fa1ed5ab45 100644 --- a/src/Core/Models/Mail/ClaimedDomainUserNotificationViewModel.cs +++ b/src/Core/Models/Mail/ClaimedDomainUserNotificationViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class ClaimedDomainUserNotificationViewModel : BaseTitleContactUsMailModel { diff --git a/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferViewModel.cs b/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferViewModel.cs index 7e9d8ee193..adabbe0535 100644 --- a/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferViewModel.cs +++ b/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseOfferViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail.FamiliesForEnterprise; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail.FamiliesForEnterprise; public class FamiliesForEnterpriseOfferViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseRemoveOfferViewModel.cs b/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseRemoveOfferViewModel.cs index 46cbb4d0a0..57c1a4bed5 100644 --- a/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseRemoveOfferViewModel.cs +++ b/src/Core/Models/Mail/FamiliesForEnterprise/FamiliesForEnterpriseRemoveOfferViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail.FamiliesForEnterprise; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail.FamiliesForEnterprise; public class FamiliesForEnterpriseRemoveOfferViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/InvoiceUpcomingViewModel.cs b/src/Core/Models/Mail/InvoiceUpcomingViewModel.cs index db62178a0a..b63213b811 100644 --- a/src/Core/Models/Mail/InvoiceUpcomingViewModel.cs +++ b/src/Core/Models/Mail/InvoiceUpcomingViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class InvoiceUpcomingViewModel : BaseMailModel { @@ -7,4 +10,9 @@ public class InvoiceUpcomingViewModel : BaseMailModel public List Items { get; set; } public bool MentionInvoices { get; set; } public string UpdateBillingInfoUrl { get; set; } = "https://bitwarden.com/help/update-billing-info/"; + public string CollectionMethod { get; set; } + public bool HasPaymentMethod { get; set; } + public string PaymentMethodDescription { get; set; } + public string HelpUrl { get; set; } = "https://bitwarden.com/help/"; + public string ContactUrl { get; set; } = "https://bitwarden.com/contact/"; } diff --git a/src/Core/Models/Mail/LicenseExpiredViewModel.cs b/src/Core/Models/Mail/LicenseExpiredViewModel.cs index 922b35cfb1..e1d5578b80 100644 --- a/src/Core/Models/Mail/LicenseExpiredViewModel.cs +++ b/src/Core/Models/Mail/LicenseExpiredViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class LicenseExpiredViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/MailMessage.cs b/src/Core/Models/Mail/MailMessage.cs index df444c77f5..15e8e885cf 100644 --- a/src/Core/Models/Mail/MailMessage.cs +++ b/src/Core/Models/Mail/MailMessage.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class MailMessage { diff --git a/src/Core/Models/Mail/MailQueueMessage.cs b/src/Core/Models/Mail/MailQueueMessage.cs index d413c5f1a5..53f31becba 100644 --- a/src/Core/Models/Mail/MailQueueMessage.cs +++ b/src/Core/Models/Mail/MailQueueMessage.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Utilities; namespace Bit.Core.Models.Mail; diff --git a/src/Core/Models/Mail/NewDeviceLoggedInModel.cs b/src/Core/Models/Mail/NewDeviceLoggedInModel.cs index 6d55a19b64..0e4a49503a 100644 --- a/src/Core/Models/Mail/NewDeviceLoggedInModel.cs +++ b/src/Core/Models/Mail/NewDeviceLoggedInModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class NewDeviceLoggedInModel : BaseMailModel { diff --git a/src/Core/Models/Mail/OrganizationDomainUnverifiedViewModel.cs b/src/Core/Models/Mail/OrganizationDomainUnverifiedViewModel.cs index a0547ed3a1..2d00c7056f 100644 --- a/src/Core/Models/Mail/OrganizationDomainUnverifiedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationDomainUnverifiedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class OrganizationDomainUnverifiedViewModel { diff --git a/src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs b/src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs index 4e13abf656..4c4c265aba 100644 --- a/src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs +++ b/src/Core/Models/Mail/OrganizationInitiateDeleteModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.Auth.Models.Mail; diff --git a/src/Core/Models/Mail/OrganizationInvitesInfo.cs b/src/Core/Models/Mail/OrganizationInvitesInfo.cs index 267c386a66..af53f23a8a 100644 --- a/src/Core/Models/Mail/OrganizationInvitesInfo.cs +++ b/src/Core/Models/Mail/OrganizationInvitesInfo.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Models.Business; using Bit.Core.Billing.Enums; using Bit.Core.Entities; @@ -34,7 +37,6 @@ public class OrganizationInvitesInfo public bool OrgSsoEnabled { get; } public string OrgSsoIdentifier { get; } public bool OrgSsoLoginRequiredPolicyEnabled { get; } - public IEnumerable<(OrganizationUser OrgUser, ExpiringToken Token)> OrgUserTokenPairs { get; } public Dictionary OrgUserHasExistingUserDict { get; } diff --git a/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs b/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs index 425b853d3e..1f393bf578 100644 --- a/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs +++ b/src/Core/Models/Mail/OrganizationSeatsAutoscaledViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class OrganizationSeatsAutoscaledViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs b/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs index ad9c48ab31..24b65e807c 100644 --- a/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationSeatsMaxReachedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class OrganizationSeatsMaxReachedViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs b/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs index c814a3e564..f60c5aeaaa 100644 --- a/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationServiceAccountsMaxReachedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class OrganizationServiceAccountsMaxReachedViewModel { diff --git a/src/Core/Models/Mail/OrganizationUserAcceptedViewModel.cs b/src/Core/Models/Mail/OrganizationUserAcceptedViewModel.cs index 543df2fc65..80c22f287d 100644 --- a/src/Core/Models/Mail/OrganizationUserAcceptedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserAcceptedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class OrganizationUserAcceptedViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/OrganizationUserConfirmedViewModel.cs b/src/Core/Models/Mail/OrganizationUserConfirmedViewModel.cs index 8254d3d841..a93d0bfdb4 100644 --- a/src/Core/Models/Mail/OrganizationUserConfirmedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserConfirmedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class OrganizationUserConfirmedViewModel : BaseTitleContactUsMailModel { diff --git a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs index f34f414ce8..669887c4b6 100644 --- a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Core.Auth.Models.Business; using Bit.Core.Entities; using Bit.Core.Settings; @@ -18,7 +21,11 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel ExpiringToken expiringToken, GlobalSettings globalSettings) { - var freeOrgTitle = "A Bitwarden member invited you to an organization. Join now to start securing your passwords!"; + const string freeOrgTitle = "A Bitwarden member invited you to an organization. " + + "Join now to start securing your passwords!"; + + var userHasExistingUser = orgInvitesInfo.OrgUserHasExistingUserDict[orgUser.Id]; + return new OrganizationUserInvitedViewModel { TitleFirst = orgInvitesInfo.IsFreeOrg ? freeOrgTitle : "Join ", @@ -27,7 +34,7 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel ? string.Empty : CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), TitleThird = orgInvitesInfo.IsFreeOrg ? string.Empty : " on Bitwarden and start securing your passwords!", - OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false) + orgUser.Status, + OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), Email = WebUtility.UrlEncode(orgUser.Email), OrganizationId = orgUser.OrganizationId.ToString(), OrganizationUserId = orgUser.Id.ToString(), @@ -41,7 +48,9 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel OrgSsoIdentifier = orgInvitesInfo.OrgSsoIdentifier, OrgSsoEnabled = orgInvitesInfo.OrgSsoEnabled, OrgSsoLoginRequiredPolicyEnabled = orgInvitesInfo.OrgSsoLoginRequiredPolicyEnabled, - OrgUserHasExistingUser = orgInvitesInfo.OrgUserHasExistingUserDict[orgUser.Id] + OrgUserHasExistingUser = userHasExistingUser, + JoinOrganizationButtonText = userHasExistingUser || orgInvitesInfo.IsFreeOrg ? "Accept invitation" : "Finish account setup", + IsFreeOrg = orgInvitesInfo.IsFreeOrg }; } @@ -57,6 +66,8 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel public bool OrgSsoEnabled { get; set; } public bool OrgSsoLoginRequiredPolicyEnabled { get; set; } public bool OrgUserHasExistingUser { get; set; } + public string JoinOrganizationButtonText { get; set; } = "Join Organization"; + public bool IsFreeOrg { get; set; } public string Url { diff --git a/src/Core/Models/Mail/OrganizationUserRemovedForPolicySingleOrgViewModel.cs b/src/Core/Models/Mail/OrganizationUserRemovedForPolicySingleOrgViewModel.cs index 46020ae46a..edebaab5b4 100644 --- a/src/Core/Models/Mail/OrganizationUserRemovedForPolicySingleOrgViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserRemovedForPolicySingleOrgViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class OrganizationUserRemovedForPolicySingleOrgViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/OrganizationUserRemovedForPolicyTwoStepViewModel.cs b/src/Core/Models/Mail/OrganizationUserRemovedForPolicyTwoStepViewModel.cs index cd4528ad50..6d87dd1b58 100644 --- a/src/Core/Models/Mail/OrganizationUserRemovedForPolicyTwoStepViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserRemovedForPolicyTwoStepViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class OrganizationUserRemovedForPolicyTwoStepViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/OrganizationUserRevokedForPolicySingleOrgViewModel.cs b/src/Core/Models/Mail/OrganizationUserRevokedForPolicySingleOrgViewModel.cs index 27c784bd15..a278f6cc51 100644 --- a/src/Core/Models/Mail/OrganizationUserRevokedForPolicySingleOrgViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserRevokedForPolicySingleOrgViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class OrganizationUserRevokedForPolicySingleOrgViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/OrganizationUserRevokedForPolicyTwoFactorViewModel.cs b/src/Core/Models/Mail/OrganizationUserRevokedForPolicyTwoFactorViewModel.cs index 9286ee74b3..d0eafbb2a9 100644 --- a/src/Core/Models/Mail/OrganizationUserRevokedForPolicyTwoFactorViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserRevokedForPolicyTwoFactorViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class OrganizationUserRevokedForPolicyTwoFactorViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/Provider/ProviderInitiateDeleteModel.cs b/src/Core/Models/Mail/Provider/ProviderInitiateDeleteModel.cs index a5071527fe..851f76ac83 100644 --- a/src/Core/Models/Mail/Provider/ProviderInitiateDeleteModel.cs +++ b/src/Core/Models/Mail/Provider/ProviderInitiateDeleteModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail.Provider; public class ProviderInitiateDeleteModel : BaseMailModel { diff --git a/src/Core/Models/Mail/Provider/ProviderSetupInviteViewModel.cs b/src/Core/Models/Mail/Provider/ProviderSetupInviteViewModel.cs index f351a5fe1b..607f19c605 100644 --- a/src/Core/Models/Mail/Provider/ProviderSetupInviteViewModel.cs +++ b/src/Core/Models/Mail/Provider/ProviderSetupInviteViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail.Provider; public class ProviderSetupInviteViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethodViewModel.cs b/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethodViewModel.cs index 114aaa7c95..ef21c0dcd5 100644 --- a/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethodViewModel.cs +++ b/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethodViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail.Provider; public class ProviderUpdatePaymentMethodViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/Provider/ProviderUserConfirmedViewModel.cs b/src/Core/Models/Mail/Provider/ProviderUserConfirmedViewModel.cs index 30d24ad1e9..4cc7edfdbc 100644 --- a/src/Core/Models/Mail/Provider/ProviderUserConfirmedViewModel.cs +++ b/src/Core/Models/Mail/Provider/ProviderUserConfirmedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail.Provider; public class ProviderUserConfirmedViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/Provider/ProviderUserInvitedViewModel.cs b/src/Core/Models/Mail/Provider/ProviderUserInvitedViewModel.cs index e418d30f21..0bce7c7005 100644 --- a/src/Core/Models/Mail/Provider/ProviderUserInvitedViewModel.cs +++ b/src/Core/Models/Mail/Provider/ProviderUserInvitedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail.Provider; public class ProviderUserInvitedViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/Provider/ProviderUserRemovedViewModel.cs b/src/Core/Models/Mail/Provider/ProviderUserRemovedViewModel.cs index aef9d9c593..5753d0b317 100644 --- a/src/Core/Models/Mail/Provider/ProviderUserRemovedViewModel.cs +++ b/src/Core/Models/Mail/Provider/ProviderUserRemovedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail.Provider; public class ProviderUserRemovedViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs b/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs index d41ca41146..e1dee2a89e 100644 --- a/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs +++ b/src/Core/Models/Mail/SecurityTaskNotificationViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class SecurityTaskNotificationViewModel : BaseMailModel { diff --git a/src/Core/Models/Mail/TwoFactorEmailTokenViewModel.cs b/src/Core/Models/Mail/TwoFactorEmailTokenViewModel.cs index dbd47af35a..5265601984 100644 --- a/src/Core/Models/Mail/TwoFactorEmailTokenViewModel.cs +++ b/src/Core/Models/Mail/TwoFactorEmailTokenViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; /// /// This view model is used to set-up email two factor authentication, to log in with email two factor authentication, @@ -22,4 +25,9 @@ public class TwoFactorEmailTokenViewModel : BaseMailModel public string TimeZone { get; set; } public string DeviceIp { get; set; } public string DeviceType { get; set; } + /// + /// Depending on the context, we may want to show a reminder to the user that they should enable two factor authentication. + /// This is not relevant when the user is using the email to verify setting up 2FA, so we hide it in that case. + /// + public bool DisplayTwoFactorReminder { get; set; } } diff --git a/src/Core/Models/Mail/UpdateTempPasswordViewModel.cs b/src/Core/Models/Mail/UpdateTempPasswordViewModel.cs index 6e45df5305..4c0c3519e0 100644 --- a/src/Core/Models/Mail/UpdateTempPasswordViewModel.cs +++ b/src/Core/Models/Mail/UpdateTempPasswordViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class UpdateTempPasswordViewModel { diff --git a/src/Core/Models/Mail/UserVerificationEmailTokenViewModel.cs b/src/Core/Models/Mail/UserVerificationEmailTokenViewModel.cs index b8850b5f00..43270efe38 100644 --- a/src/Core/Models/Mail/UserVerificationEmailTokenViewModel.cs +++ b/src/Core/Models/Mail/UserVerificationEmailTokenViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class UserVerificationEmailTokenViewModel : BaseMailModel { diff --git a/src/Core/Models/OrganizationConnectionConfigs/BillingSyncConfig.cs b/src/Core/Models/OrganizationConnectionConfigs/BillingSyncConfig.cs index 07f07093d2..a72cebfbee 100644 --- a/src/Core/Models/OrganizationConnectionConfigs/BillingSyncConfig.cs +++ b/src/Core/Models/OrganizationConnectionConfigs/BillingSyncConfig.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.OrganizationConnectionConfigs; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.OrganizationConnectionConfigs; public class BillingSyncConfig : IConnectionConfig { diff --git a/src/Core/Models/PushNotification.cs b/src/Core/Models/PushNotification.cs index 83c6f577d4..c4ae1e2858 100644 --- a/src/Core/Models/PushNotification.cs +++ b/src/Core/Models/PushNotification.cs @@ -1,9 +1,11 @@ -#nullable enable -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.NotificationCenter.Enums; namespace Bit.Core.Models; +// New push notification payload models should not be defined in this file +// they should instead be defined in file owned by your team. + public class PushNotificationData { public PushNotificationData(PushType type, T payload, string? contextId) @@ -84,3 +86,14 @@ public class OrganizationCollectionManagementPushNotification public bool LimitCollectionDeletion { get; init; } public bool LimitItemDeletion { get; init; } } + +public class OrganizationBankAccountVerifiedPushNotification +{ + public Guid OrganizationId { get; set; } +} + +public class ProviderBankAccountVerifiedPushNotification +{ + public Guid ProviderId { get; set; } + public Guid AdminId { get; set; } +} diff --git a/src/Core/Models/Stripe/StripeSubscriptionListOptions.cs b/src/Core/Models/Stripe/StripeSubscriptionListOptions.cs index f32576c407..34662ecdbb 100644 --- a/src/Core/Models/Stripe/StripeSubscriptionListOptions.cs +++ b/src/Core/Models/Stripe/StripeSubscriptionListOptions.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.BitStripe; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.BitStripe; // Stripe's SubscriptionListOptions model has a complex input for date filters. // It expects a dictionary, and has lots of validation rules around what can have a value and what can't. diff --git a/src/Core/NotificationCenter/Repositories/INotificationRepository.cs b/src/Core/NotificationCenter/Repositories/INotificationRepository.cs index 21604ed169..0d0ea80491 100644 --- a/src/Core/NotificationCenter/Repositories/INotificationRepository.cs +++ b/src/Core/NotificationCenter/Repositories/INotificationRepository.cs @@ -32,4 +32,13 @@ public interface INotificationRepository : IRepository /// Task> GetByUserIdAndStatusAsync(Guid userId, ClientType clientType, NotificationStatusFilter? statusFilter, PageOptions pageOptions); + + /// + /// Marks notifications as deleted by a taskId. + /// + /// The unique identifier of the task. + /// + /// A collection of UserIds for the notifications that are now marked as deleted. + /// + Task> MarkNotificationsAsDeletedByTask(Guid taskId); } diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommand.cs index 1d7eb8f2ba..929c236ef2 100644 --- a/src/Core/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommand.cs @@ -52,6 +52,11 @@ public class BulkAddCollectionAccessCommand : IBulkAddCollectionAccessCommand throw new BadRequestException("No collections were provided."); } + if (collections.Any(c => c.Type == CollectionType.DefaultUserCollection)) + { + throw new BadRequestException("You cannot add access to collections with the type as DefaultUserCollection."); + } + var orgId = collections.First().OrganizationId; if (collections.Any(c => c.OrganizationId != orgId)) diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/CreateCollectionCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/CreateCollectionCommand.cs index 1cec2f5cc4..e6f3489d2a 100644 --- a/src/Core/OrganizationFeatures/OrganizationCollections/CreateCollectionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationCollections/CreateCollectionCommand.cs @@ -1,4 +1,8 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; @@ -26,6 +30,11 @@ public class CreateCollectionCommand : ICreateCollectionCommand public async Task CreateAsync(Collection collection, IEnumerable groups = null, IEnumerable users = null) { + if (collection.Type == CollectionType.DefaultUserCollection) + { + throw new BadRequestException("You cannot create a collection with the type as DefaultUserCollection."); + } + var org = await _organizationRepository.GetByIdAsync(collection.OrganizationId); if (org == null) { diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommand.cs index 11f29f228f..4f678633a9 100644 --- a/src/Core/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommand.cs @@ -1,4 +1,6 @@ using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -20,6 +22,11 @@ public class DeleteCollectionCommand : IDeleteCollectionCommand public async Task DeleteAsync(Collection collection) { + if (collection.Type == CollectionType.DefaultUserCollection) + { + throw new BadRequestException("You cannot delete a collection with the type as DefaultUserCollection."); + } + await _collectionRepository.DeleteAsync(collection); await _eventService.LogCollectionEventAsync(collection, Enums.EventType.Collection_Deleted, DateTime.UtcNow); } @@ -33,6 +40,11 @@ public class DeleteCollectionCommand : IDeleteCollectionCommand public async Task DeleteManyAsync(IEnumerable collections) { + if (collections.Any(c => c.Type == Enums.CollectionType.DefaultUserCollection)) + { + throw new BadRequestException("You cannot delete collections with the type as DefaultUserCollection."); + } + await _collectionRepository.DeleteManyAsync(collections.Select(c => c.Id)); await _eventService.LogCollectionEventsAsync(collections.Select(c => (c, Enums.EventType.Collection_Deleted, (DateTime?)DateTime.UtcNow))); } diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/ICreateCollectionCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/ICreateCollectionCommand.cs index b73afb4d1e..8a715c3052 100644 --- a/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/ICreateCollectionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/ICreateCollectionCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Models.Data; namespace Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/IUpdateCollectionCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/IUpdateCollectionCommand.cs index 94d4d1d1f8..14200ae7fc 100644 --- a/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/IUpdateCollectionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationCollections/Interfaces/IUpdateCollectionCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Models.Data; namespace Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommand.cs index 3985b6a919..0a03261330 100644 --- a/src/Core/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommand.cs @@ -1,4 +1,8 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; @@ -26,6 +30,11 @@ public class UpdateCollectionCommand : IUpdateCollectionCommand public async Task UpdateAsync(Collection collection, IEnumerable groups = null, IEnumerable users = null) { + if (collection.Type == CollectionType.DefaultUserCollection) + { + throw new BadRequestException("You cannot edit a collection with the type as DefaultUserCollection."); + } + var org = await _organizationRepository.GetByIdAsync(collection.OrganizationId); if (org == null) { diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/Interfaces/IGetOrganizationLicenseQuery.cs b/src/Core/OrganizationFeatures/OrganizationLicenses/Interfaces/IGetOrganizationLicenseQuery.cs deleted file mode 100644 index 312b80a466..0000000000 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/Interfaces/IGetOrganizationLicenseQuery.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Entities; -using Bit.Core.Models.Business; - -namespace Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; - -public interface ICloudGetOrganizationLicenseQuery -{ - Task GetLicenseAsync(Organization organization, Guid installationId, - int? version = null); -} - -public interface ISelfHostedGetOrganizationLicenseQuery -{ - Task GetLicenseAsync(Organization organization, OrganizationConnection billingSyncConnection); -} diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/Interfaces/IUpdateOrganizationLicenseCommand.cs b/src/Core/OrganizationFeatures/OrganizationLicenses/Interfaces/IUpdateOrganizationLicenseCommand.cs deleted file mode 100644 index 78f590e59f..0000000000 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/Interfaces/IUpdateOrganizationLicenseCommand.cs +++ /dev/null @@ -1,13 +0,0 @@ -#nullable enable - -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Models.Business; -using Bit.Core.Models.Data.Organizations; - -namespace Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; - -public interface IUpdateOrganizationLicenseCommand -{ - Task UpdateLicenseAsync(SelfHostedOrganizationDetails selfHostedOrganization, - OrganizationLicense license, Organization? currentOrganizationUsingLicenseKey); -} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index ef78e966f6..da05bc929c 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.OrganizationAuth.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Groups; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Import; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections; @@ -12,6 +13,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; @@ -22,8 +24,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v using Bit.Core.Models.Business.Tokenables; using Bit.Core.OrganizationFeatures.OrganizationCollections; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; -using Bit.Core.OrganizationFeatures.OrganizationLicenses; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -56,7 +56,6 @@ public static class OrganizationServiceCollectionExtensions services.AddOrganizationApiKeyCommandsQueries(); services.AddOrganizationCollectionCommands(); services.AddOrganizationGroupCommands(); - services.AddOrganizationLicenseCommandsQueries(); services.AddOrganizationDomainCommandsQueries(); services.AddOrganizationSignUpCommands(); services.AddOrganizationDeleteCommands(); @@ -74,6 +73,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationDeleteCommands(this IServiceCollection services) @@ -129,10 +129,13 @@ public static class OrganizationServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services) @@ -157,13 +160,6 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); } - private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - } - private static void AddOrganizationDomainCommandsQueries(this IServiceCollection services) { services.AddScoped(); @@ -188,18 +184,19 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } // TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CancelSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CancelSponsorshipCommand.cs index 111cec395c..713862154a 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CancelSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CancelSponsorshipCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SendSponsorshipOfferCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SendSponsorshipOfferCommand.cs index e070b263a3..489b0c9021 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SendSponsorshipOfferCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SendSponsorshipOfferCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateRedemptionTokenCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateRedemptionTokenCommand.cs index fc3f5b1321..b3675a1f0f 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateRedemptionTokenCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateRedemptionTokenCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Models.Business.Tokenables; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs index 6b8d6d6771..dcda77acea 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/ValidateSponsorshipCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Extensions; using Bit.Core.Entities; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/SelfHosted/SelfHostedSyncSponsorshipsCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/SelfHosted/SelfHostedSyncSponsorshipsCommand.cs index 0d22b53bad..9a995a9cf0 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/SelfHosted/SelfHostedSyncSponsorshipsCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/SelfHosted/SelfHostedSyncSponsorshipsCommand.cs @@ -1,6 +1,9 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.IdentityServer; using Bit.Core.Models.Api.Request.OrganizationSponsorships; using Bit.Core.Models.Api.Response.OrganizationSponsorships; using Bit.Core.Models.Data.Organizations.OrganizationSponsorships; diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/OrganizationSubscriptionServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/OrganizationSubscriptionServiceCollectionExtensions.cs index 2e65fd0563..ef12e1d0f1 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/OrganizationSubscriptionServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/OrganizationSubscriptionServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ -using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions; @@ -7,7 +9,10 @@ public static class OrganizationSubscriptionServiceCollectionExtensions { public static void AddOrganizationSubscriptionServices(this IServiceCollection services) { - services.AddScoped(); - services.AddScoped(); + services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); } } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs index 91f6516501..739dca5228 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -52,15 +55,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs await FinalizeSubscriptionAdjustmentAsync(update); - if (update.SmSeatAutoscaleLimitReached) - { - await SendSeatLimitEmailAsync(update.Organization); - } - - if (update.SmServiceAccountAutoscaleLimitReached) - { - await SendServiceAccountLimitEmailAsync(update.Organization); - } + await ValidateAutoScaleLimitsAsync(update); } private async Task FinalizeSubscriptionAdjustmentAsync(SecretsManagerSubscriptionUpdate update) @@ -97,7 +92,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs OrganizationUserType.Owner)) .Select(u => u.Email).Distinct(); - await _mailService.SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmSeats.Value, ownerEmails); + await _mailService.SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmSeats!.Value, ownerEmails); } catch (Exception e) @@ -114,7 +109,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs OrganizationUserType.Owner)) .Select(u => u.Email).Distinct(); - await _mailService.SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts.Value, ownerEmails); + await _mailService.SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts!.Value, ownerEmails); } catch (Exception e) @@ -194,7 +189,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs throw new BadRequestException("Organization has no Secrets Manager seat limit, no need to adjust seats"); } - if (update.Autoscaling && update.SmSeats.Value < organization.SmSeats.Value) + if (update.Autoscaling && update.SmSeats!.Value < organization.SmSeats.Value) { throw new BadRequestException("Cannot use autoscaling to subtract seats."); } @@ -208,7 +203,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs } // Check autoscale maximum seats - if (update.MaxAutoscaleSmSeats.HasValue && update.SmSeats.Value > update.MaxAutoscaleSmSeats.Value) + if (update.MaxAutoscaleSmSeats.HasValue && update.SmSeats!.Value > update.MaxAutoscaleSmSeats.Value) { var message = update.Autoscaling ? "Secrets Manager seat limit has been reached." @@ -217,7 +212,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs } // Check minimum seats included with plan - if (plan.SecretsManager.BaseSeats > update.SmSeats.Value) + if (plan.SecretsManager.BaseSeats > update.SmSeats!.Value) { throw new BadRequestException($"Plan has a minimum of {plan.SecretsManager.BaseSeats} Secrets Manager seats."); } @@ -257,7 +252,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs throw new BadRequestException("Organization has no machine accounts limit, no need to adjust machine accounts"); } - if (update.Autoscaling && update.SmServiceAccounts.Value < organization.SmServiceAccounts.Value) + if (update.Autoscaling && update.SmServiceAccounts!.Value < organization.SmServiceAccounts.Value) { throw new BadRequestException("Cannot use autoscaling to subtract machine accounts."); } @@ -273,7 +268,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs // Check autoscale maximum service accounts if (update.MaxAutoscaleSmServiceAccounts.HasValue && - update.SmServiceAccounts.Value > update.MaxAutoscaleSmServiceAccounts.Value) + update.SmServiceAccounts!.Value > update.MaxAutoscaleSmServiceAccounts.Value) { var message = update.Autoscaling ? "Secrets Manager machine account limit has been reached." @@ -282,7 +277,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs } // Check minimum service accounts included with plan - if (plan.SecretsManager.BaseServiceAccount > update.SmServiceAccounts.Value) + if (plan.SecretsManager.BaseServiceAccount > update.SmServiceAccounts!.Value) { throw new BadRequestException($"Plan has a minimum of {plan.SecretsManager.BaseServiceAccount} machine accounts."); } @@ -320,7 +315,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs throw new BadRequestException($"Cannot set max Secrets Manager seat autoscaling below current Secrets Manager seat count."); } - if (plan.SecretsManager.MaxSeats.HasValue && update.MaxAutoscaleSmSeats.Value > plan.SecretsManager.MaxSeats) + if (plan.SecretsManager.MaxSeats.HasValue && plan.SecretsManager.MaxSeats.Value > 0 && update.MaxAutoscaleSmSeats.Value > plan.SecretsManager.MaxSeats) { throw new BadRequestException(string.Concat( $"Your plan has a Secrets Manager seat limit of {plan.SecretsManager.MaxSeats}, ", @@ -376,4 +371,55 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs await _eventService.LogOrganizationEventAsync(org, orgEvent.Value); } } + + private async Task ValidateAutoScaleLimitsAsync(SecretsManagerSubscriptionUpdate update) + { + var (smSeatAutoScaleLimitReached, smServiceAccountsLimitReached) = await AreAutoscaleLimitsReachedAsync(update); + + if (smSeatAutoScaleLimitReached) + { + await SendSeatLimitEmailAsync(update.Organization); + } + + if (smServiceAccountsLimitReached) + { + await SendServiceAccountLimitEmailAsync(update.Organization); + } + } + + private async Task<(bool, bool)> AreAutoscaleLimitsReachedAsync(SecretsManagerSubscriptionUpdate update) + { + var smSeatAutoScaleLimitReached = false; + var smServiceAccountsLimitReached = false; + + var (occupiedSmSeats, occupiedSmServiceAccounts) = await GetOccupiedSmSeatsAndServiceAccountsAsync(update.Organization.Id); + + if (occupiedSmSeats > 0 + && update.MaxAutoscaleSmSeats is not null + && occupiedSmSeats == update.MaxAutoscaleSmSeats!.Value) + { + smSeatAutoScaleLimitReached = true; + } + + if (occupiedSmServiceAccounts > 0 + && update.MaxAutoscaleSmServiceAccounts is not null + && occupiedSmServiceAccounts == update.MaxAutoscaleSmServiceAccounts!.Value) + { + smServiceAccountsLimitReached = true; + } + + return (smSeatAutoScaleLimitReached, smServiceAccountsLimitReached); + } + + /// + /// Requests the number of Secret Manager seats and service accounts are currently used by the organization + /// + /// The id of the organization + /// A tuple containing the occupied seats and the occupied service account counts + private async Task<(int, int)> GetOccupiedSmSeatsAndServiceAccountsAsync(Guid organizationId) + { + var occupiedSmSeatsTask = _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organizationId); + var occupiedServiceAccountsTask = _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organizationId); + return (await occupiedSmSeatsTask, await occupiedServiceAccountsTask); + } } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 761f59920c..2b39e6cca6 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -1,13 +1,16 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -262,7 +265,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand organization.UseApi = newPlan.HasApi; organization.UseSso = newPlan.HasSso; organization.UseOrganizationDomains = newPlan.HasOrganizationDomains; - organization.UseKeyConnector = newPlan.HasKeyConnector; + organization.UseKeyConnector = newPlan.HasKeyConnector ? organization.UseKeyConnector : false; organization.UseScim = newPlan.HasScim; organization.UseResetPassword = newPlan.HasResetPassword; organization.SelfHost = newPlan.HasSelfHost; diff --git a/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs b/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs index 0d287a2229..6b76bc35f0 100644 --- a/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs +++ b/src/Core/PhishingDomainFeatures/AzurePhishingDomainStorageService.cs @@ -1,4 +1,7 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Bit.Core.Settings; diff --git a/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs b/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs index 2685d36a7f..6b0027062c 100644 --- a/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs +++ b/src/Core/PhishingDomainFeatures/CloudPhishingDomainRelayQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.PhishingDomainFeatures.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.PhishingDomainFeatures.Interfaces; using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Extensions.Logging; diff --git a/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs b/src/Core/Platform/Push/Engines/AzureQueuePushEngine.cs similarity index 91% rename from src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs rename to src/Core/Platform/Push/Engines/AzureQueuePushEngine.cs index 94a20f1971..e8c8790c64 100644 --- a/src/Core/Platform/Push/Services/AzureQueuePushNotificationService.cs +++ b/src/Core/Platform/Push/Engines/AzureQueuePushEngine.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.Text.Json; +using System.Text.Json; using Azure.Storage.Queues; using Bit.Core.Context; using Bit.Core.Enums; @@ -13,17 +12,16 @@ using Microsoft.Extensions.Logging; namespace Bit.Core.Platform.Push.Internal; -public class AzureQueuePushNotificationService : IPushEngine +public class AzureQueuePushEngine : IPushEngine { private readonly QueueClient _queueClient; private readonly IHttpContextAccessor _httpContextAccessor; - public AzureQueuePushNotificationService( + public AzureQueuePushEngine( [FromKeyedServices("notifications")] QueueClient queueClient, IHttpContextAccessor httpContextAccessor, IGlobalSettings globalSettings, - ILogger logger, - TimeProvider timeProvider) + ILogger logger) { _queueClient = queueClient; _httpContextAccessor = httpContextAccessor; diff --git a/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs b/src/Core/Platform/Push/Engines/MultiServicePushNotificationService.cs similarity index 91% rename from src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs rename to src/Core/Platform/Push/Engines/MultiServicePushNotificationService.cs index 404b153fa3..1dbd2c83e5 100644 --- a/src/Core/Platform/Push/Services/MultiServicePushNotificationService.cs +++ b/src/Core/Platform/Push/Engines/MultiServicePushNotificationService.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Settings; using Bit.Core.Vault.Entities; using Microsoft.Extensions.Logging; @@ -8,7 +7,7 @@ namespace Bit.Core.Platform.Push.Internal; public class MultiServicePushNotificationService : IPushNotificationService { - private readonly IEnumerable _services; + private readonly IPushEngine[] _services; public Guid InstallationId { get; } @@ -22,7 +21,8 @@ public class MultiServicePushNotificationService : IPushNotificationService GlobalSettings globalSettings, TimeProvider timeProvider) { - _services = services; + // Filter out any NoopPushEngine's + _services = [.. services.Where(engine => engine is not NoopPushEngine)]; Logger = logger; Logger.LogInformation("Hub services: {Services}", _services.Count()); diff --git a/src/Core/Platform/Push/Services/NoopPushNotificationService.cs b/src/Core/Platform/Push/Engines/NoopPushEngine.cs similarity index 75% rename from src/Core/Platform/Push/Services/NoopPushNotificationService.cs rename to src/Core/Platform/Push/Engines/NoopPushEngine.cs index e6f71de006..029d6dd556 100644 --- a/src/Core/Platform/Push/Services/NoopPushNotificationService.cs +++ b/src/Core/Platform/Push/Engines/NoopPushEngine.cs @@ -1,10 +1,9 @@ -#nullable enable -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Vault.Entities; namespace Bit.Core.Platform.Push.Internal; -internal class NoopPushNotificationService : IPushEngine +internal class NoopPushEngine : IPushEngine { public Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable? collectionIds) => Task.CompletedTask; diff --git a/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs b/src/Core/Platform/Push/Engines/NotificationsApiPushEngine.cs similarity index 87% rename from src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs rename to src/Core/Platform/Push/Engines/NotificationsApiPushEngine.cs index 5e0d584ba8..add53278ff 100644 --- a/src/Core/Platform/Push/Services/NotificationsApiPushNotificationService.cs +++ b/src/Core/Platform/Push/Engines/NotificationsApiPushEngine.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Core.Context; +using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Services; @@ -8,23 +7,22 @@ using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -// This service is not in the `Internal` namespace because it has direct external references. -namespace Bit.Core.Platform.Push; +namespace Bit.Core.Platform.Push.Internal; /// /// Sends non-mobile push notifications to the Azure Queue Api, later received by Notifications Api. /// Used by Cloud-Hosted environments. /// Received by AzureQueueHostedService message receiver in Notifications project. /// -public class NotificationsApiPushNotificationService : BaseIdentityClientService, IPushEngine +public class NotificationsApiPushEngine : BaseIdentityClientService, IPushEngine { private readonly IHttpContextAccessor _httpContextAccessor; - public NotificationsApiPushNotificationService( + public NotificationsApiPushEngine( IHttpClientFactory httpFactory, GlobalSettings globalSettings, IHttpContextAccessor httpContextAccessor, - ILogger logger) + ILogger logger) : base( httpFactory, globalSettings.BaseServiceUri.InternalNotifications, diff --git a/src/Core/Platform/Push/Services/RelayPushNotificationService.cs b/src/Core/Platform/Push/Engines/RelayPushEngine.cs similarity index 94% rename from src/Core/Platform/Push/Services/RelayPushNotificationService.cs rename to src/Core/Platform/Push/Engines/RelayPushEngine.cs index 9f2289b864..cff077c850 100644 --- a/src/Core/Platform/Push/Services/RelayPushNotificationService.cs +++ b/src/Core/Platform/Push/Engines/RelayPushEngine.cs @@ -1,7 +1,6 @@ -#nullable enable +using Bit.Core.Auth.IdentityServer; using Bit.Core.Context; using Bit.Core.Enums; -using Bit.Core.IdentityServer; using Bit.Core.Models; using Bit.Core.Models.Api; using Bit.Core.Repositories; @@ -19,18 +18,18 @@ namespace Bit.Core.Platform.Push.Internal; /// Used by Self-Hosted environments. /// Received by PushController endpoint in Api project. /// -public class RelayPushNotificationService : BaseIdentityClientService, IPushEngine +public class RelayPushEngine : BaseIdentityClientService, IPushEngine { private readonly IDeviceRepository _deviceRepository; private readonly IHttpContextAccessor _httpContextAccessor; - public RelayPushNotificationService( + public RelayPushEngine( IHttpClientFactory httpFactory, IDeviceRepository deviceRepository, GlobalSettings globalSettings, IHttpContextAccessor httpContextAccessor, - ILogger logger) + ILogger logger) : base( httpFactory, globalSettings.PushRelayBaseUri, diff --git a/src/Core/Platform/Push/Services/IPushEngine.cs b/src/Core/Platform/Push/IPushEngine.cs similarity index 76% rename from src/Core/Platform/Push/Services/IPushEngine.cs rename to src/Core/Platform/Push/IPushEngine.cs index bde4ddaf4b..ca00dae3ad 100644 --- a/src/Core/Platform/Push/Services/IPushEngine.cs +++ b/src/Core/Platform/Push/IPushEngine.cs @@ -1,8 +1,7 @@ -#nullable enable -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Vault.Entities; -namespace Bit.Core.Platform.Push; +namespace Bit.Core.Platform.Push.Internal; public interface IPushEngine { diff --git a/src/Core/Platform/Push/Services/IPushNotificationService.cs b/src/Core/Platform/Push/IPushNotificationService.cs similarity index 81% rename from src/Core/Platform/Push/Services/IPushNotificationService.cs rename to src/Core/Platform/Push/IPushNotificationService.cs index 58b8a4722d..32a488b827 100644 --- a/src/Core/Platform/Push/Services/IPushNotificationService.cs +++ b/src/Core/Platform/Push/IPushNotificationService.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Enums; using Bit.Core.Models; @@ -10,10 +9,27 @@ using Microsoft.Extensions.Logging; namespace Bit.Core.Platform.Push; +/// +/// Used to Push notifications to end-user devices. +/// +/// +/// New notifications should not be wired up inside this service. You may either directly call the +/// method in your service to send your notification or if you want your notification +/// sent by other teams you can make an extension method on this service with a well typed definition +/// of your notification. You may also make your own service that injects this and exposes methods for each of +/// your notifications. +/// public interface IPushNotificationService { + private const string ServiceDeprecation = "Do not use the services exposed here, instead use your own services injected in your service."; + + [Obsolete(ServiceDeprecation, DiagnosticId = "BWP0001")] Guid InstallationId { get; } + + [Obsolete(ServiceDeprecation, DiagnosticId = "BWP0001")] TimeProvider TimeProvider { get; } + + [Obsolete(ServiceDeprecation, DiagnosticId = "BWP0001")] ILogger Logger { get; } #region Legacy method, to be removed soon. @@ -80,7 +96,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, }); @@ -94,7 +112,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, }); @@ -108,7 +128,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, }); @@ -122,7 +144,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, }); @@ -136,7 +160,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, }); @@ -150,7 +176,9 @@ public interface IPushNotificationService Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = excludeCurrentContextFromPush, }); @@ -231,7 +259,9 @@ public interface IPushNotificationService ClientType = notification.ClientType, UserId = notification.UserId, OrganizationId = notification.OrganizationId, +#pragma warning disable BWP0001 // Type or member is obsolete InstallationId = notification.Global ? InstallationId : null, +#pragma warning restore BWP0001 // Type or member is obsolete TaskId = notification.TaskId, Title = notification.Title, Body = notification.Body, @@ -246,7 +276,9 @@ public interface IPushNotificationService { // TODO: Think about this a bit more target = NotificationTarget.Installation; +#pragma warning disable BWP0001 // Type or member is obsolete targetId = InstallationId; +#pragma warning restore BWP0001 // Type or member is obsolete } else if (notification.UserId.HasValue) { @@ -260,7 +292,9 @@ public interface IPushNotificationService } else { +#pragma warning disable BWP0001 // Type or member is obsolete Logger.LogWarning("Invalid notification id {NotificationId} push notification", notification.Id); +#pragma warning restore BWP0001 // Type or member is obsolete return Task.CompletedTask; } @@ -285,7 +319,9 @@ public interface IPushNotificationService ClientType = notification.ClientType, UserId = notification.UserId, OrganizationId = notification.OrganizationId, +#pragma warning disable BWP0001 // Type or member is obsolete InstallationId = notification.Global ? InstallationId : null, +#pragma warning restore BWP0001 // Type or member is obsolete TaskId = notification.TaskId, Title = notification.Title, Body = notification.Body, @@ -302,7 +338,9 @@ public interface IPushNotificationService { // TODO: Think about this a bit more target = NotificationTarget.Installation; +#pragma warning disable BWP0001 // Type or member is obsolete targetId = InstallationId; +#pragma warning restore BWP0001 // Type or member is obsolete } else if (notification.UserId.HasValue) { @@ -316,7 +354,9 @@ public interface IPushNotificationService } else { +#pragma warning disable BWP0001 // Type or member is obsolete Logger.LogWarning("Invalid notification status id {NotificationId} push notification", notification.Id); +#pragma warning restore BWP0001 // Type or member is obsolete return Task.CompletedTask; } @@ -359,20 +399,6 @@ public interface IPushNotificationService ExcludeCurrentContext = true, }); - Task PushSyncOrganizationStatusAsync(Organization organization) - => PushAsync(new PushNotification - { - Type = PushType.SyncOrganizationStatusChanged, - Target = NotificationTarget.Organization, - TargetId = organization.Id, - Payload = new OrganizationStatusPushNotification - { - OrganizationId = organization.Id, - Enabled = organization.Enabled, - }, - ExcludeCurrentContext = false, - }); - Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization) => PushAsync(new PushNotification { @@ -389,16 +415,18 @@ public interface IPushNotificationService ExcludeCurrentContext = false, }); - Task PushPendingSecurityTasksAsync(Guid userId) + Task PushRefreshSecurityTasksAsync(Guid userId) => PushAsync(new PushNotification { - Type = PushType.PendingSecurityTasks, + Type = PushType.RefreshSecurityTasks, Target = NotificationTarget.User, TargetId = userId, Payload = new UserPushNotification { UserId = userId, +#pragma warning disable BWP0001 // Type or member is obsolete Date = TimeProvider.GetUtcNow().UtcDateTime, +#pragma warning restore BWP0001 // Type or member is obsolete }, ExcludeCurrentContext = false, }); @@ -406,6 +434,12 @@ public interface IPushNotificationService Task PushCipherAsync(Cipher cipher, PushType pushType, IEnumerable? collectionIds); + /// + /// Pushes a notification to devices based on the settings given to us in . + /// + /// The type of the payload to be sent along with the notification. + /// + /// A task that is NOT guarunteed to have sent the notification by the time the task resolves. Task PushAsync(PushNotification pushNotification) where T : class; } diff --git a/src/Core/Platform/Push/Services/IPushRelayer.cs b/src/Core/Platform/Push/IPushRelayer.cs similarity index 97% rename from src/Core/Platform/Push/Services/IPushRelayer.cs rename to src/Core/Platform/Push/IPushRelayer.cs index fde0a521f3..1fb75e0dfc 100644 --- a/src/Core/Platform/Push/Services/IPushRelayer.cs +++ b/src/Core/Platform/Push/IPushRelayer.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.Enums; namespace Bit.Core.Platform.Push.Internal; diff --git a/src/Core/NotificationHub/INotificationHubClientProxy.cs b/src/Core/Platform/Push/NotificationHub/INotificationHubClientProxy.cs similarity index 82% rename from src/Core/NotificationHub/INotificationHubClientProxy.cs rename to src/Core/Platform/Push/NotificationHub/INotificationHubClientProxy.cs index 78eb0206d6..8b765d209b 100644 --- a/src/Core/NotificationHub/INotificationHubClientProxy.cs +++ b/src/Core/Platform/Push/NotificationHub/INotificationHubClientProxy.cs @@ -1,8 +1,6 @@ using Microsoft.Azure.NotificationHubs; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.Push.Internal; public interface INotificationHubProxy { diff --git a/src/Core/NotificationHub/INotificationHubPool.cs b/src/Core/Platform/Push/NotificationHub/INotificationHubPool.cs similarity index 81% rename from src/Core/NotificationHub/INotificationHubPool.cs rename to src/Core/Platform/Push/NotificationHub/INotificationHubPool.cs index 25a31d62f4..3d5767623b 100644 --- a/src/Core/NotificationHub/INotificationHubPool.cs +++ b/src/Core/Platform/Push/NotificationHub/INotificationHubPool.cs @@ -1,8 +1,6 @@ using Microsoft.Azure.NotificationHubs; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.Push.Internal; public interface INotificationHubPool { diff --git a/src/Core/NotificationHub/NotificationHubClientProxy.cs b/src/Core/Platform/Push/NotificationHub/NotificationHubClientProxy.cs similarity index 94% rename from src/Core/NotificationHub/NotificationHubClientProxy.cs rename to src/Core/Platform/Push/NotificationHub/NotificationHubClientProxy.cs index b47069fe21..026f3179d1 100644 --- a/src/Core/NotificationHub/NotificationHubClientProxy.cs +++ b/src/Core/Platform/Push/NotificationHub/NotificationHubClientProxy.cs @@ -1,8 +1,6 @@ using Microsoft.Azure.NotificationHubs; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.Push.Internal; public class NotificationHubClientProxy : INotificationHubProxy { diff --git a/src/Core/NotificationHub/NotificationHubConnection.cs b/src/Core/Platform/Push/NotificationHub/NotificationHubConnection.cs similarity index 99% rename from src/Core/NotificationHub/NotificationHubConnection.cs rename to src/Core/Platform/Push/NotificationHub/NotificationHubConnection.cs index a61f2ded8f..22c1668506 100644 --- a/src/Core/NotificationHub/NotificationHubConnection.cs +++ b/src/Core/Platform/Push/NotificationHub/NotificationHubConnection.cs @@ -6,9 +6,7 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.Azure.NotificationHubs; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.Push.Internal; public class NotificationHubConnection { diff --git a/src/Core/NotificationHub/NotificationHubPool.cs b/src/Core/Platform/Push/NotificationHub/NotificationHubPool.cs similarity index 98% rename from src/Core/NotificationHub/NotificationHubPool.cs rename to src/Core/Platform/Push/NotificationHub/NotificationHubPool.cs index 38192c11fc..c3dc47809f 100644 --- a/src/Core/NotificationHub/NotificationHubPool.cs +++ b/src/Core/Platform/Push/NotificationHub/NotificationHubPool.cs @@ -3,9 +3,7 @@ using Bit.Core.Utilities; using Microsoft.Azure.NotificationHubs; using Microsoft.Extensions.Logging; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.Push.Internal; public class NotificationHubPool : INotificationHubPool { diff --git a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs b/src/Core/Platform/Push/NotificationHub/NotificationHubPushEngine.cs similarity index 95% rename from src/Core/NotificationHub/NotificationHubPushNotificationService.cs rename to src/Core/Platform/Push/NotificationHub/NotificationHubPushEngine.cs index 81ec82a25d..1d1eb2ef70 100644 --- a/src/Core/NotificationHub/NotificationHubPushNotificationService.cs +++ b/src/Core/Platform/Push/NotificationHub/NotificationHubPushEngine.cs @@ -1,28 +1,23 @@ -#nullable enable -using System.Text.Json; +using System.Text.Json; using System.Text.RegularExpressions; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Models.Data; -using Bit.Core.Platform.Push; -using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.Push.Internal; /// /// Sends mobile push notifications to the Azure Notification Hub. /// Used by Cloud-Hosted environments. /// Received by Firebase for Android or APNS for iOS. /// -public class NotificationHubPushNotificationService : IPushEngine, IPushRelayer +public class NotificationHubPushEngine : IPushEngine, IPushRelayer { private readonly IInstallationDeviceRepository _installationDeviceRepository; private readonly IHttpContextAccessor _httpContextAccessor; @@ -30,11 +25,11 @@ public class NotificationHubPushNotificationService : IPushEngine, IPushRelayer private readonly INotificationHubPool _notificationHubPool; private readonly ILogger _logger; - public NotificationHubPushNotificationService( + public NotificationHubPushEngine( IInstallationDeviceRepository installationDeviceRepository, INotificationHubPool notificationHubPool, IHttpContextAccessor httpContextAccessor, - ILogger logger, + ILogger logger, IGlobalSettings globalSettings) { _installationDeviceRepository = installationDeviceRepository; diff --git a/src/Core/Platform/Push/NotificationInfoAttribute.cs b/src/Core/Platform/Push/NotificationInfoAttribute.cs new file mode 100644 index 0000000000..ff134f5fda --- /dev/null +++ b/src/Core/Platform/Push/NotificationInfoAttribute.cs @@ -0,0 +1,44 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Platform.Push; + +/// +/// Used to annotate information about a given . +/// +[AttributeUsage(AttributeTargets.Field)] +public class NotificationInfoAttribute : Attribute +{ + // Once upon a time we can feed this information into a C# analyzer to make sure that we validate + // the callsites of IPushNotificationService.PushAsync uses the correct payload type for the notification type + // for now this only exists as forced documentation to teams who create a push type. + + // It's especially on purpose that we allow ourselves to take a type name via just the string, + // this allows teams to make a push type that is only sent with a payload that exists in a separate assembly than + // this one. + + public NotificationInfoAttribute(string team, Type payloadType) + // It should be impossible to reference an unnamed type for an attributes constructor so this assertion should be safe. + : this(team, payloadType.FullName!) + { + Team = team; + } + + public NotificationInfoAttribute(string team, string payloadTypeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(team); + ArgumentException.ThrowIfNullOrWhiteSpace(payloadTypeName); + + Team = team; + PayloadTypeName = payloadTypeName; + } + + /// + /// The name of the team that owns this . + /// + public string Team { get; } + + /// + /// The fully qualified type name of the payload that should be used when sending a notification of this type. + /// + public string PayloadTypeName { get; } +} diff --git a/src/Core/Platform/Push/Services/PushNotification.cs b/src/Core/Platform/Push/PushNotification.cs similarity index 96% rename from src/Core/Platform/Push/Services/PushNotification.cs rename to src/Core/Platform/Push/PushNotification.cs index e1d3f44cd8..3150b854a4 100644 --- a/src/Core/Platform/Push/Services/PushNotification.cs +++ b/src/Core/Platform/Push/PushNotification.cs @@ -6,6 +6,9 @@ namespace Bit.Core.Platform.Push; /// /// Contains constants for all the available targets for a given notification. /// +/// +/// Please reach out to the Platform team if you need a new target added. +/// public enum NotificationTarget { /// diff --git a/src/Core/Platform/Push/PushServiceCollectionExtensions.cs b/src/Core/Platform/Push/PushServiceCollectionExtensions.cs new file mode 100644 index 0000000000..b54ae64c08 --- /dev/null +++ b/src/Core/Platform/Push/PushServiceCollectionExtensions.cs @@ -0,0 +1,82 @@ +using Azure.Storage.Queues; +using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for adding the Push feature. +/// +public static class PushServiceCollectionExtensions +{ + /// + /// Adds a to the services that can be used to send push notifications to + /// end user devices. This method is safe to be ran multiple time provided does not + /// change between calls. + /// + /// The to add services to. + /// The to use to configure services. + /// The for additional chaining. + public static IServiceCollection AddPush(this IServiceCollection services, GlobalSettings globalSettings) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(globalSettings); + + services.TryAddSingleton(TimeProvider.System); + services.TryAddSingleton(); + + if (globalSettings.SelfHosted) + { + if (globalSettings.Installation.Id == Guid.Empty) + { + throw new InvalidOperationException("Installation Id must be set for self-hosted installations."); + } + + if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) && + CoreHelpers.SettingHasValue(globalSettings.Installation.Key)) + { + // TODO: We should really define the HttpClient we will use here + services.AddHttpClient(); + services.AddHttpContextAccessor(); + // We also depend on IDeviceRepository but don't explicitly add it right now. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } + + if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) && + CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications)) + { + // TODO: We should really define the HttpClient we will use here + services.AddHttpClient(); + services.AddHttpContextAccessor(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } + } + else + { + services.TryAddSingleton(); + services.AddHttpContextAccessor(); + + // We also depend on IInstallationDeviceRepository but don't explicitly add it right now. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + services.TryAddSingleton(); + + if (CoreHelpers.SettingHasValue(globalSettings.Notifications?.ConnectionString)) + { + services.TryAddKeyedSingleton("notifications", static (sp, _) => + { + var gs = sp.GetRequiredService(); + return new QueueClient(gs.Notifications.ConnectionString, "notifications"); + }); + + // We not IHttpContextAccessor will be added above, no need to do it here. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } + } + + return services; + } +} diff --git a/src/Core/Platform/Push/PushType.cs b/src/Core/Platform/Push/PushType.cs new file mode 100644 index 0000000000..7765c1aa66 --- /dev/null +++ b/src/Core/Platform/Push/PushType.cs @@ -0,0 +1,99 @@ +using Bit.Core.Platform.Push; + +// TODO: This namespace should change to `Bit.Core.Platform.Push` +namespace Bit.Core.Enums; + +/// +/// +/// +/// +/// +/// When adding a new enum member you must annotate it with a +/// this is enforced with a unit test. It is preferred that you do NOT add new usings for the type referenced +/// in . +/// +/// +/// You may and are +/// +/// +public enum PushType : byte +{ + // When adding a new enum member you must annotate it with a NotificationInfoAttribute this is enforced with a unit + // test. It is preferred that you do NOT add new usings for the type referenced for the payload. You are also + // encouraged to define the payload type in your own teams owned code. + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))] + SyncCipherUpdate = 0, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))] + SyncCipherCreate = 1, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))] + SyncLoginDelete = 2, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncFolderPushNotification))] + SyncFolderDelete = 3, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))] + SyncCiphers = 4, + + [NotificationInfo("not-specified", typeof(Models.UserPushNotification))] + SyncVault = 5, + + [NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.UserPushNotification))] + SyncOrgKeys = 6, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncFolderPushNotification))] + SyncFolderCreate = 7, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncFolderPushNotification))] + SyncFolderUpdate = 8, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.SyncCipherPushNotification))] + SyncCipherDelete = 9, + + [NotificationInfo("not-specified", typeof(Models.UserPushNotification))] + SyncSettings = 10, + + [NotificationInfo("not-specified", typeof(Models.UserPushNotification))] + LogOut = 11, + + [NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))] + SyncSendCreate = 12, + + [NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))] + SyncSendUpdate = 13, + + [NotificationInfo("@bitwarden/team-tools-dev", typeof(Models.SyncSendPushNotification))] + SyncSendDelete = 14, + + [NotificationInfo("@bitwarden/team-auth-dev", typeof(Models.AuthRequestPushNotification))] + AuthRequest = 15, + + [NotificationInfo("@bitwarden/team-auth-dev", typeof(Models.AuthRequestPushNotification))] + AuthRequestResponse = 16, + + [NotificationInfo("not-specified", typeof(Models.UserPushNotification))] + SyncOrganizations = 17, + + [NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.OrganizationStatusPushNotification))] + SyncOrganizationStatusChanged = 18, + + [NotificationInfo("@bitwarden/team-admin-console-dev", typeof(Models.OrganizationCollectionManagementPushNotification))] + SyncOrganizationCollectionSettingChanged = 19, + + [NotificationInfo("not-specified", typeof(Models.NotificationPushNotification))] + Notification = 20, + + [NotificationInfo("not-specified", typeof(Models.NotificationPushNotification))] + NotificationStatus = 21, + + [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))] + RefreshSecurityTasks = 22, + + [NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))] + OrganizationBankAccountVerified = 23, + + [NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))] + ProviderBankAccountVerified = 24 +} diff --git a/src/Core/Platform/Push/Services/IPushRegistrationService.cs b/src/Core/Platform/PushRegistration/IPushRegistrationService.cs similarity index 79% rename from src/Core/Platform/Push/Services/IPushRegistrationService.cs rename to src/Core/Platform/PushRegistration/IPushRegistrationService.cs index 8e34e5e316..d650842f32 100644 --- a/src/Core/Platform/Push/Services/IPushRegistrationService.cs +++ b/src/Core/Platform/PushRegistration/IPushRegistrationService.cs @@ -1,10 +1,10 @@ -#nullable enable - -using Bit.Core.Enums; -using Bit.Core.NotificationHub; +using Bit.Core.Enums; +using Bit.Core.Platform.PushRegistration; +// TODO: Change this namespace to `Bit.Core.Platform.PushRegistration namespace Bit.Core.Platform.Push; + public interface IPushRegistrationService { Task CreateOrUpdateRegistrationAsync(PushRegistrationData data, string deviceId, string userId, string identifier, DeviceType type, IEnumerable organizationIds, Guid installationId); diff --git a/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs b/src/Core/Platform/PushRegistration/NoopPushRegistrationService.cs similarity index 86% rename from src/Core/Platform/Push/Services/NoopPushRegistrationService.cs rename to src/Core/Platform/PushRegistration/NoopPushRegistrationService.cs index 32efc95ce6..0aebcbf1f3 100644 --- a/src/Core/Platform/Push/Services/NoopPushRegistrationService.cs +++ b/src/Core/Platform/PushRegistration/NoopPushRegistrationService.cs @@ -1,9 +1,7 @@ -#nullable enable +using Bit.Core.Enums; +using Bit.Core.Platform.Push; -using Bit.Core.Enums; -using Bit.Core.NotificationHub; - -namespace Bit.Core.Platform.Push.Internal; +namespace Bit.Core.Platform.PushRegistration.Internal; public class NoopPushRegistrationService : IPushRegistrationService { diff --git a/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs b/src/Core/Platform/PushRegistration/NotificationHubPushRegistrationService.cs similarity index 99% rename from src/Core/NotificationHub/NotificationHubPushRegistrationService.cs rename to src/Core/Platform/PushRegistration/NotificationHubPushRegistrationService.cs index dc494eecd6..ee02e2bdf1 100644 --- a/src/Core/NotificationHub/NotificationHubPushRegistrationService.cs +++ b/src/Core/Platform/PushRegistration/NotificationHubPushRegistrationService.cs @@ -6,14 +6,13 @@ using System.Text.Json; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using Bit.Core.Utilities; using Microsoft.Azure.NotificationHubs; using Microsoft.Extensions.Logging; -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.PushRegistration.Internal; public class NotificationHubPushRegistrationService : IPushRegistrationService { diff --git a/src/Core/NotificationHub/PushRegistrationData.cs b/src/Core/Platform/PushRegistration/PushRegistrationData.cs similarity index 92% rename from src/Core/NotificationHub/PushRegistrationData.cs rename to src/Core/Platform/PushRegistration/PushRegistrationData.cs index c11ee7be23..844de4e1be 100644 --- a/src/Core/NotificationHub/PushRegistrationData.cs +++ b/src/Core/Platform/PushRegistration/PushRegistrationData.cs @@ -1,6 +1,4 @@ -namespace Bit.Core.NotificationHub; - -#nullable enable +namespace Bit.Core.Platform.PushRegistration; public record struct WebPushRegistrationData { diff --git a/src/Core/Platform/PushRegistration/PushRegistrationServiceCollectionExtensions.cs b/src/Core/Platform/PushRegistration/PushRegistrationServiceCollectionExtensions.cs new file mode 100644 index 0000000000..841902c964 --- /dev/null +++ b/src/Core/Platform/PushRegistration/PushRegistrationServiceCollectionExtensions.cs @@ -0,0 +1,54 @@ +using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; +using Bit.Core.Platform.PushRegistration.Internal; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for adding the Push Registration feature. +/// +public static class PushRegistrationServiceCollectionExtensions +{ + /// + /// Adds a to the service collection. + /// + /// The to add services to. + /// The for chaining. + public static IServiceCollection AddPushRegistration(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + // TODO: Should add feature that brings in IInstallationDeviceRepository once that is featurized + + // Register all possible variants under there concrete type. + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.AddHttpClient(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddSingleton(static sp => + { + var globalSettings = sp.GetRequiredService(); + + if (globalSettings.SelfHosted) + { + if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) && + CoreHelpers.SettingHasValue(globalSettings.Installation.Key)) + { + return sp.GetRequiredService(); + } + + return sp.GetRequiredService(); + } + + return sp.GetRequiredService(); + }); + + return services; + } +} diff --git a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs b/src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs similarity index 94% rename from src/Core/Platform/Push/Services/RelayPushRegistrationService.cs rename to src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs index 20e405935b..0925e92f64 100644 --- a/src/Core/Platform/Push/Services/RelayPushRegistrationService.cs +++ b/src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs @@ -1,14 +1,12 @@ -#nullable enable - +using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; -using Bit.Core.IdentityServer; using Bit.Core.Models.Api; -using Bit.Core.NotificationHub; +using Bit.Core.Platform.Push; using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Extensions.Logging; -namespace Bit.Core.Platform.Push.Internal; +namespace Bit.Core.Platform.PushRegistration.Internal; public class RelayPushRegistrationService : BaseIdentityClientService, IPushRegistrationService { diff --git a/src/Core/Platform/X509ChainCustomization/PostConfigureX509ChainOptions.cs b/src/Core/Platform/X509ChainCustomization/PostConfigureX509ChainOptions.cs deleted file mode 100644 index 963294e85f..0000000000 --- a/src/Core/Platform/X509ChainCustomization/PostConfigureX509ChainOptions.cs +++ /dev/null @@ -1,96 +0,0 @@ -#nullable enable - -using System.Security.Cryptography.X509Certificates; -using Bit.Core.Settings; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Bit.Core.Platform.X509ChainCustomization; - -internal sealed class PostConfigureX509ChainOptions : IPostConfigureOptions -{ - const string CertificateSearchPattern = "*.crt"; - - private readonly ILogger _logger; - private readonly IHostEnvironment _hostEnvironment; - private readonly GlobalSettings _globalSettings; - - public PostConfigureX509ChainOptions( - ILogger logger, - IHostEnvironment hostEnvironment, - GlobalSettings globalSettings) - { - _logger = logger; - _hostEnvironment = hostEnvironment; - _globalSettings = globalSettings; - } - - public void PostConfigure(string? name, X509ChainOptions options) - { - // We don't register or request a named instance of these options, - // so don't customize it. - if (name != Options.DefaultName) - { - return; - } - - // We only allow this setting to be configured on self host. - if (!_globalSettings.SelfHosted) - { - options.AdditionalCustomTrustCertificatesDirectory = null; - return; - } - - if (options.AdditionalCustomTrustCertificates != null) - { - // Additional certificates were added directly, this overwrites the need to - // read them from the directory. - _logger.LogInformation( - "Additional custom trust certificates were added directly, skipping loading them from '{Directory}'", - options.AdditionalCustomTrustCertificatesDirectory - ); - return; - } - - if (string.IsNullOrEmpty(options.AdditionalCustomTrustCertificatesDirectory)) - { - return; - } - - if (!Directory.Exists(options.AdditionalCustomTrustCertificatesDirectory)) - { - // The default directory is volume mounted via the default Bitwarden setup process. - // If the directory doesn't exist it could indicate a error in configuration but this - // directory is never expected in a normal development environment so lower the log - // level in that case. - var logLevel = _hostEnvironment.IsDevelopment() - ? LogLevel.Debug - : LogLevel.Warning; - _logger.Log( - logLevel, - "An additional custom trust certificate directory was given '{Directory}' but that directory does not exist.", - options.AdditionalCustomTrustCertificatesDirectory - ); - return; - } - - var certificates = new List(); - - foreach (var certFile in Directory.EnumerateFiles(options.AdditionalCustomTrustCertificatesDirectory, CertificateSearchPattern)) - { - certificates.Add(new X509Certificate2(certFile)); - } - - if (options.AdditionalCustomTrustCertificatesDirectory != X509ChainOptions.DefaultAdditionalCustomTrustCertificatesDirectory && certificates.Count == 0) - { - // They have intentionally given us a non-default directory but there weren't certificates, that is odd. - _logger.LogWarning( - "No additional custom trust certificates were found in '{Directory}'", - options.AdditionalCustomTrustCertificatesDirectory - ); - } - - options.AdditionalCustomTrustCertificates = certificates; - } -} diff --git a/src/Core/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensions.cs b/src/Core/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensions.cs deleted file mode 100644 index 46bd5b37e6..0000000000 --- a/src/Core/Platform/X509ChainCustomization/X509ChainCustomizationServiceCollectionExtensions.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Bit.Core.Platform.X509ChainCustomization; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; - -namespace Microsoft.Extensions.DependencyInjection; - -/// -/// Extension methods for setting up the ability to provide customization to how X509 chain validation works in an . -/// -public static class X509ChainCustomizationServiceCollectionExtensions -{ - /// - /// Configures X509ChainPolicy customization through the root level X509ChainOptions configuration section - /// and configures the primary to use custom certificate validation - /// when customized to do so. - /// - /// The . - /// The for additional chaining. - public static IServiceCollection AddX509ChainCustomization(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddOptions() - .BindConfiguration(nameof(X509ChainOptions)); - - // Use TryAddEnumerable to make sure `PostConfigureX509ChainOptions` isn't added multiple - // times even if this method is called multiple times. - services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureX509ChainOptions>()); - - services.AddHttpClient() - .ConfigureHttpClientDefaults(builder => - { - builder.ConfigurePrimaryHttpMessageHandler(sp => - { - var x509ChainOptions = sp.GetRequiredService>().Value; - - var handler = new HttpClientHandler(); - - if (x509ChainOptions.TryGetCustomRemoteCertificateValidationCallback(out var callback)) - { - handler.ServerCertificateCustomValidationCallback = (sender, certificate, chain, errors) => - { - return callback(certificate, chain, errors); - }; - } - - return handler; - }); - }); - - return services; - } -} diff --git a/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs b/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs deleted file mode 100644 index 6cd06acf3c..0000000000 --- a/src/Core/Platform/X509ChainCustomization/X509ChainOptions.cs +++ /dev/null @@ -1,81 +0,0 @@ -#nullable enable - -using System.Diagnostics.CodeAnalysis; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; - -namespace Bit.Core.Platform.X509ChainCustomization; - -/// -/// Allows for customization of the and access to a custom server certificate validator -/// if customization has been made. -/// -public sealed class X509ChainOptions -{ - // This is the directory that we historically used to allow certificates be added inside our container - // and then on start of the container we would move them to `/usr/local/share/ca-certificates/` and call - // `update-ca-certificates` but since that operation requires root we can't do it in a rootless container. - // Ref: https://github.com/bitwarden/server/blob/67d7d685a619a5fc413f8532dacb09681ee5c956/src/Api/entrypoint.sh#L38-L41 - public const string DefaultAdditionalCustomTrustCertificatesDirectory = "/etc/bitwarden/ca-certificates/"; - - /// - /// A directory where additional certificates should be read from and included in . - /// - /// - /// Only certificates suffixed with *.crt will be read. If is - /// set, then this directory will not be read from. - /// - public string? AdditionalCustomTrustCertificatesDirectory { get; set; } = DefaultAdditionalCustomTrustCertificatesDirectory; - - /// - /// A list of additional certificates that should be included in . - /// - /// - /// If this value is set manually, then will be ignored. - /// - public List? AdditionalCustomTrustCertificates { get; set; } - - /// - /// Attempts to retrieve a custom remote certificate validation callback. - /// - /// - /// Returns when we have custom remote certification that should be added, - /// when no custom validation is needed and the default validation callback should - /// be used instead. - /// - [MemberNotNullWhen(true, nameof(AdditionalCustomTrustCertificates))] - public bool TryGetCustomRemoteCertificateValidationCallback( - [MaybeNullWhen(false)] out Func callback) - { - callback = null; - if (AdditionalCustomTrustCertificates == null || AdditionalCustomTrustCertificates.Count == 0) - { - return false; - } - - // Do this outside of the callback so that we aren't opening the root store every request. - using var store = new X509Store(StoreName.Root, StoreLocation.LocalMachine, OpenFlags.ReadOnly); - var rootCertificates = store.Certificates; - - // Ref: https://github.com/dotnet/runtime/issues/39835#issuecomment-663020581 - callback = (certificate, chain, errors) => - { - if (chain == null || certificate == null) - { - return false; - } - - chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; - - // We want our additional certificates to be in addition to the machines root store. - chain.ChainPolicy.CustomTrustStore.AddRange(rootCertificates); - - foreach (var additionalCertificate in AdditionalCustomTrustCertificates) - { - chain.ChainPolicy.CustomTrustStore.Add(additionalCertificate); - } - return chain.Build(certificate); - }; - return true; - } -} diff --git a/src/Core/Repositories/ICollectionCipherRepository.cs b/src/Core/Repositories/ICollectionCipherRepository.cs index 9494fec0ec..f7a4081b73 100644 --- a/src/Core/Repositories/ICollectionCipherRepository.cs +++ b/src/Core/Repositories/ICollectionCipherRepository.cs @@ -8,6 +8,7 @@ public interface ICollectionCipherRepository { Task> GetManyByUserIdAsync(Guid userId); Task> GetManyByOrganizationIdAsync(Guid organizationId); + Task> GetManySharedByOrganizationIdAsync(Guid organizationId); Task> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId); Task UpdateCollectionsAsync(Guid cipherId, Guid userId, IEnumerable collectionIds); Task UpdateCollectionsForAdminAsync(Guid cipherId, Guid organizationId, IEnumerable collectionIds); diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index 1d41a6ee1f..f86147ca7d 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -20,8 +20,14 @@ public interface ICollectionRepository : IRepository /// Task> GetManyByOrganizationIdAsync(Guid organizationId); + /// + /// + /// Excludes default collections (My Items collections) - used by Admin Console Collections tab. + /// + Task> GetManySharedCollectionsByOrganizationIdAsync(Guid organizationId); + /// - /// Return all collections that belong to the organization. Includes group/user access relationships for each collection. + /// Return all shared collections that belong to the organization. Includes group/user access relationships for each collection. /// Task>> GetManyByOrganizationIdWithAccessAsync(Guid organizationId); @@ -34,9 +40,10 @@ public interface ICollectionRepository : IRepository Task> GetManyByUserIdAsync(Guid userId); /// - /// Returns all collections for an organization, including permission info for the specified user. + /// Returns all shared collections for an organization, including permission info for the specified user. /// This does not perform any authorization checks internally! /// Optionally, you can include access relationships for other Groups/Users and the collections. + /// Excludes default collections (My Items collections) - used by Admin Console Collections tab. /// Task> GetManyByOrganizationIdWithPermissionsAsync(Guid organizationId, Guid userId, bool includeAccessRelationships); @@ -55,4 +62,13 @@ public interface ICollectionRepository : IRepository Task DeleteManyAsync(IEnumerable collectionIds); Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, IEnumerable users, IEnumerable groups); + + /// + /// Creates default user collections for the specified organization users if they do not already have one. + /// + /// The Organization ID. + /// The Organization User IDs to create default collections for. + /// The encrypted string to use as the default collection name. + /// + Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName); } diff --git a/src/Core/Resources/SharedResources.en.resx b/src/Core/Resources/SharedResources.en.resx index 90a791222f..28ae70ca96 100644 --- a/src/Core/Resources/SharedResources.en.resx +++ b/src/Core/Resources/SharedResources.en.resx @@ -389,29 +389,14 @@ If SAML Binding Type is set to artifact, identity provider resolution service URL is required. - If Identity Provider Entity ID is not a URL, single sign on service URL is required. + Single sign on service URL is required. The configured authentication scheme is not valid: "{0}" - - No scheme or handler for this SSO configuration found. - - - SSO is not yet enabled for this organization. - - - No SSO configuration exists for this organization. - - - SSO is not allowed for this organization. - Organization not found from identifier. - - No organization identifier provided. - Invalid authentication options provided to SAML2 scheme. @@ -532,6 +517,12 @@ To accept your invite to {0}, you must first log in using your master password. Once your invite has been accepted, you will be able to log in using SSO. + + Your access to organization {0} has been revoked. Please contact your administrator for assistance. + + + Your access to organization {0} is in an unknown state. Please contact your administrator for assistance. + You were removed from the organization managing single sign-on for your account. Contact the organization administrator for help regaining access to your account. @@ -685,4 +676,7 @@ Single sign on redirect token is invalid or expired. + + Invalid SSO identifier + diff --git a/src/Core/SecretsManager/Commands/Porting/SMImport.cs b/src/Core/SecretsManager/Commands/Porting/SMImport.cs index 0e61b3acaf..80c6c65f6e 100644 --- a/src/Core/SecretsManager/Commands/Porting/SMImport.cs +++ b/src/Core/SecretsManager/Commands/Porting/SMImport.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.SecretsManager.Commands.Porting; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.SecretsManager.Commands.Porting; public class SMImport { diff --git a/src/Core/SecretsManager/Commands/Projects/Interfaces/ICreateProjectCommand.cs b/src/Core/SecretsManager/Commands/Projects/Interfaces/ICreateProjectCommand.cs index db377e220e..a1793cc73a 100644 --- a/src/Core/SecretsManager/Commands/Projects/Interfaces/ICreateProjectCommand.cs +++ b/src/Core/SecretsManager/Commands/Projects/Interfaces/ICreateProjectCommand.cs @@ -1,4 +1,4 @@ -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; using Bit.Core.SecretsManager.Entities; namespace Bit.Core.SecretsManager.Commands.Projects.Interfaces; diff --git a/src/Core/SecretsManager/Models/Data/ApiKeyClientSecretDetails.cs b/src/Core/SecretsManager/Models/Data/ApiKeyClientSecretDetails.cs index bcf84942b5..3cb2d22b37 100644 --- a/src/Core/SecretsManager/Models/Data/ApiKeyClientSecretDetails.cs +++ b/src/Core/SecretsManager/Models/Data/ApiKeyClientSecretDetails.cs @@ -1,4 +1,7 @@ -using Bit.Core.SecretsManager.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.SecretsManager.Entities; namespace Bit.Core.SecretsManager.Models.Data; diff --git a/src/Core/SecretsManager/Models/Data/ApiKeyDetails.cs b/src/Core/SecretsManager/Models/Data/ApiKeyDetails.cs index 47fea5a52e..2210138f3b 100644 --- a/src/Core/SecretsManager/Models/Data/ApiKeyDetails.cs +++ b/src/Core/SecretsManager/Models/Data/ApiKeyDetails.cs @@ -1,4 +1,7 @@ -using System.Diagnostics.CodeAnalysis; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Diagnostics.CodeAnalysis; using Bit.Core.SecretsManager.Entities; namespace Bit.Core.SecretsManager.Models.Data; diff --git a/src/Core/SecretsManager/Models/Data/PeopleGrantees.cs b/src/Core/SecretsManager/Models/Data/PeopleGrantees.cs index db70312694..2678c10978 100644 --- a/src/Core/SecretsManager/Models/Data/PeopleGrantees.cs +++ b/src/Core/SecretsManager/Models/Data/PeopleGrantees.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.SecretsManager.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.SecretsManager.Models.Data; public class PeopleGrantees { diff --git a/src/Core/SecretsManager/Models/Data/ProjectPeopleAccessPolicies.cs b/src/Core/SecretsManager/Models/Data/ProjectPeopleAccessPolicies.cs index ee3a4e6141..6ba1fbfd82 100644 --- a/src/Core/SecretsManager/Models/Data/ProjectPeopleAccessPolicies.cs +++ b/src/Core/SecretsManager/Models/Data/ProjectPeopleAccessPolicies.cs @@ -1,4 +1,7 @@ -using Bit.Core.SecretsManager.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.SecretsManager.Entities; namespace Bit.Core.SecretsManager.Models.Data; diff --git a/src/Core/SecretsManager/Models/Data/ProjectPermissionDetails.cs b/src/Core/SecretsManager/Models/Data/ProjectPermissionDetails.cs index 23c01a1fdf..7f847c816d 100644 --- a/src/Core/SecretsManager/Models/Data/ProjectPermissionDetails.cs +++ b/src/Core/SecretsManager/Models/Data/ProjectPermissionDetails.cs @@ -1,4 +1,7 @@ -using Bit.Core.SecretsManager.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.SecretsManager.Entities; namespace Bit.Core.SecretsManager.Models.Data; diff --git a/src/Core/SecretsManager/Models/Data/SecretPermissionDetails.cs b/src/Core/SecretsManager/Models/Data/SecretPermissionDetails.cs index c5e15e25aa..232a96629a 100644 --- a/src/Core/SecretsManager/Models/Data/SecretPermissionDetails.cs +++ b/src/Core/SecretsManager/Models/Data/SecretPermissionDetails.cs @@ -1,4 +1,7 @@ -using Bit.Core.SecretsManager.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.SecretsManager.Entities; namespace Bit.Core.SecretsManager.Models.Data; diff --git a/src/Core/SecretsManager/Models/Data/ServiceAccountPeopleAccessPolicies.cs b/src/Core/SecretsManager/Models/Data/ServiceAccountPeopleAccessPolicies.cs index b0cd37d5c0..e26de477ff 100644 --- a/src/Core/SecretsManager/Models/Data/ServiceAccountPeopleAccessPolicies.cs +++ b/src/Core/SecretsManager/Models/Data/ServiceAccountPeopleAccessPolicies.cs @@ -1,4 +1,7 @@ -using Bit.Core.SecretsManager.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.SecretsManager.Entities; namespace Bit.Core.SecretsManager.Models.Data; diff --git a/src/Core/SecretsManager/Models/Data/ServiceAccountSecretsDetails.cs b/src/Core/SecretsManager/Models/Data/ServiceAccountSecretsDetails.cs index 67a369f02e..5fceac812d 100644 --- a/src/Core/SecretsManager/Models/Data/ServiceAccountSecretsDetails.cs +++ b/src/Core/SecretsManager/Models/Data/ServiceAccountSecretsDetails.cs @@ -1,4 +1,7 @@ -using Bit.Core.SecretsManager.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.SecretsManager.Entities; namespace Bit.Core.SecretsManager.Models.Data; diff --git a/src/Core/SecretsManager/Models/Mail/RequestSecretsManagerAccessViewModel.cs b/src/Core/SecretsManager/Models/Mail/RequestSecretsManagerAccessViewModel.cs index 1e35f97d1d..59bad9595c 100644 --- a/src/Core/SecretsManager/Models/Mail/RequestSecretsManagerAccessViewModel.cs +++ b/src/Core/SecretsManager/Models/Mail/RequestSecretsManagerAccessViewModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.SecretsManager.Models.Mail; diff --git a/src/Core/SecretsManager/Repositories/IProjectRepository.cs b/src/Core/SecretsManager/Repositories/IProjectRepository.cs index 7a084b42cc..93dabacb49 100644 --- a/src/Core/SecretsManager/Repositories/IProjectRepository.cs +++ b/src/Core/SecretsManager/Repositories/IProjectRepository.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Core/SecretsManager/Repositories/ISecretRepository.cs b/src/Core/SecretsManager/Repositories/ISecretRepository.cs index 20ebb61e9a..d491bf79d3 100644 --- a/src/Core/SecretsManager/Repositories/ISecretRepository.cs +++ b/src/Core/SecretsManager/Repositories/ISecretRepository.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; @@ -13,6 +16,7 @@ public interface ISecretRepository Task> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType); Task> GetManyByOrganizationIdInTrashByIdsAsync(Guid organizationId, IEnumerable ids); Task> GetManyByIds(IEnumerable ids); + Task> GetManyTrashedSecretsByIds(IEnumerable ids); Task GetByIdAsync(Guid id); Task CreateAsync(Secret secret, SecretAccessPoliciesUpdates accessPoliciesUpdates = null); Task UpdateAsync(Secret secret, SecretAccessPoliciesUpdates accessPoliciesUpdates = null); diff --git a/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs b/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs index a2d12578d5..26f01a4737 100644 --- a/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs +++ b/src/Core/SecretsManager/Repositories/IServiceAccountRepository.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Core/SecretsManager/Repositories/Noop/NoopProjectRepository.cs b/src/Core/SecretsManager/Repositories/Noop/NoopProjectRepository.cs index 439b32197a..043230a009 100644 --- a/src/Core/SecretsManager/Repositories/Noop/NoopProjectRepository.cs +++ b/src/Core/SecretsManager/Repositories/Noop/NoopProjectRepository.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Core/SecretsManager/Repositories/Noop/NoopSecretRepository.cs b/src/Core/SecretsManager/Repositories/Noop/NoopSecretRepository.cs index 2d434df597..b54187f8de 100644 --- a/src/Core/SecretsManager/Repositories/Noop/NoopSecretRepository.cs +++ b/src/Core/SecretsManager/Repositories/Noop/NoopSecretRepository.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates; @@ -102,4 +105,6 @@ public class NoopSecretRepository : ISecretRepository { return Task.FromResult(0); } + + public Task> GetManyTrashedSecretsByIds(IEnumerable ids) => Task.FromResult>([]); } diff --git a/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs b/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs index 7155608bcf..335ec4b2e3 100644 --- a/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs +++ b/src/Core/SecretsManager/Repositories/Noop/NoopServiceAccountRepository.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Core/Services/ICollectionService.cs b/src/Core/Services/ICollectionService.cs deleted file mode 100644 index 101f3ea23b..0000000000 --- a/src/Core/Services/ICollectionService.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.Entities; - -namespace Bit.Core.Services; - -public interface ICollectionService -{ - Task DeleteUserAsync(Collection collection, Guid organizationUserId); -} diff --git a/src/Core/Services/IDeviceService.cs b/src/Core/Services/IDeviceService.cs index cd055f8b46..ca13047c77 100644 --- a/src/Core/Services/IDeviceService.cs +++ b/src/Core/Services/IDeviceService.cs @@ -1,12 +1,12 @@ using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Entities; -using Bit.Core.NotificationHub; +using Bit.Core.Platform.PushRegistration; namespace Bit.Core.Services; public interface IDeviceService { - Task SaveAsync(WebPushRegistrationData webPush, Device device); + Task SaveAsync(WebPushRegistrationData webPush, Device device, IEnumerable organizationIds); Task SaveAsync(Device device); Task ClearTokenAsync(Device device); Task DeactivateAsync(Device device); diff --git a/src/Core/Services/IFeatureService.cs b/src/Core/Services/IFeatureService.cs index 0ac168a0cd..d1a7344ddb 100644 --- a/src/Core/Services/IFeatureService.cs +++ b/src/Core/Services/IFeatureService.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Services; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Services; public interface IFeatureService { diff --git a/src/Core/Services/II18nService.cs b/src/Core/Services/II18nService.cs index ee92664d88..9c20fc3d95 100644 --- a/src/Core/Services/II18nService.cs +++ b/src/Core/Services/II18nService.cs @@ -1,11 +1,13 @@ -using Microsoft.Extensions.Localization; +#nullable enable + +using Microsoft.Extensions.Localization; namespace Bit.Core.Services; public interface II18nService { LocalizedString GetLocalizedHtmlString(string key); - LocalizedString GetLocalizedHtmlString(string key, params object[] args); - string Translate(string key, params object[] args); - string T(string key, params object[] args); + LocalizedString GetLocalizedHtmlString(string key, params object?[] args); + string Translate(string key, params object?[] args); + string T(string key, params object?[] args); } diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index aa1c0c8c25..6e61c4f8dd 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -3,11 +3,13 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Mail; using Bit.Core.Vault.Models.Data; +using Core.Auth.Enums; namespace Bit.Core.Services; @@ -27,7 +29,9 @@ public interface IMailService Task SendCannotDeleteClaimedAccountEmailAsync(string email); Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); Task SendChangeEmailEmailAsync(string newEmailAddress, string token); - Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true); + Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose); + Task SendSendEmailOtpEmailAsync(string email, string token, string subject); + Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip); Task SendNoMasterPasswordHintEmailAsync(string email); Task SendMasterPasswordHintEmailAsync(string email, string hint); @@ -55,6 +59,14 @@ public interface IMailService DateTime dueDate, List items, bool mentionInvoices); + Task SendProviderInvoiceUpcoming( + IEnumerable emails, + decimal amount, + DateTime dueDate, + List items, + string? collectionMethod, + bool hasPaymentMethod, + string? paymentMethodDescription); Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices); Task SendAddedCreditAsync(string email, decimal amount); Task SendLicenseExpiredAsync(IEnumerable emails, string? organizationName = null); diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index af96b88ee6..e7e848bcba 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Models; using Bit.Core.Billing.Tax.Requests; @@ -22,6 +25,14 @@ public interface IPaymentService int? newlyPurchasedSecretsManagerSeats, int? newlyPurchasedAdditionalSecretsManagerServiceAccounts, int newlyPurchasedAdditionalStorage); + + /// + /// Used to update the organization's password manager subscription + /// + /// + /// + /// New seat total + /// Task AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats); Task AdjustSmSeatsAsync(Organization organization, Plan plan, int additionalSeats); Task AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId); diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index 1ba93da4fa..8a41263956 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.BitStripe; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.BitStripe; using Stripe; namespace Bit.Core.Services; @@ -45,6 +48,7 @@ public interface IStripeAdapter Task PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null); Task TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options); Task TaxIdDeleteAsync(string customerId, string taxIdId, Stripe.TaxIdDeleteOptions options = null); + Task> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null); Task> ChargeListAsync(Stripe.ChargeListOptions options); Task RefundCreateAsync(Stripe.RefundCreateOptions options); Task CardDeleteAsync(string customerId, string cardId, Stripe.CardDeleteOptions options = null); diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index e63b4e3b87..412f9db36e 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -1,7 +1,11 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; +using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; @@ -21,21 +25,6 @@ public interface IUserService Task CreateUserAsync(User user); Task CreateUserAsync(User user, string masterPasswordHash); Task SendMasterPasswordHintAsync(string email); - /// - /// Used for both email two factor and email two factor setup. - /// - /// user requesting the action - /// this controls if what verbiage is shown in the email - /// void - Task SendTwoFactorEmailAsync(User user, bool authentication = true); - /// - /// Calls the same email implementation but instead it sends the token to the account email not the - /// email set up for two-factor, since in practice they can be different. - /// - /// user attepting to login with a new device - /// void - Task SendNewDeviceVerificationEmailAsync(User user); - Task VerifyTwoFactorEmailAsync(User user, string token); Task StartWebAuthnRegistrationAsync(User user); Task DeleteWebAuthnKeyAsync(User user, int id); Task CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse); @@ -49,8 +38,6 @@ public interface IUserService Task ConvertToKeyConnectorAsync(User user); Task AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); - Task ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, string key, - KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true); Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type); @@ -87,7 +74,6 @@ public interface IUserService Task SendOTPAsync(User user); Task VerifyOTPAsync(User user, string token); Task VerifySecretAsync(User user, string secret, bool isSettingMFA = false); - Task ResendNewDeviceVerificationEmail(string email, string secret); /// /// We use this method to check if the user has an active new device verification bypass /// @@ -102,9 +88,6 @@ public interface IUserService void SetTwoFactorProvider(User user, TwoFactorProviderType type, bool setEnabled = true); - [Obsolete("To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175.")] - Task RecoverTwoFactorAsync(string email, string masterPassword, string recoveryCode); - /// /// This method is used by the TwoFactorAuthenticationValidator to recover two /// factor for a user. This allows users to be logged in after a successful recovery diff --git a/src/Core/Services/Implementations/BaseIdentityClientService.cs b/src/Core/Services/Implementations/BaseIdentityClientService.cs index f6d623692d..7281799d2f 100644 --- a/src/Core/Services/Implementations/BaseIdentityClientService.cs +++ b/src/Core/Services/Implementations/BaseIdentityClientService.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; diff --git a/src/Core/Services/Implementations/CollectionService.cs b/src/Core/Services/Implementations/CollectionService.cs deleted file mode 100644 index 2a3f8c42dc..0000000000 --- a/src/Core/Services/Implementations/CollectionService.cs +++ /dev/null @@ -1,37 +0,0 @@ -#nullable enable - -using Bit.Core.Entities; -using Bit.Core.Exceptions; -using Bit.Core.Repositories; - -namespace Bit.Core.Services; - -public class CollectionService : ICollectionService -{ - private readonly IEventService _eventService; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly ICollectionRepository _collectionRepository; - - public CollectionService( - IEventService eventService, - IOrganizationUserRepository organizationUserRepository, - ICollectionRepository collectionRepository) - { - _eventService = eventService; - _organizationUserRepository = organizationUserRepository; - _collectionRepository = collectionRepository; - } - - - - public async Task DeleteUserAsync(Collection collection, Guid organizationUserId) - { - var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - if (orgUser == null || orgUser.OrganizationId != collection.OrganizationId) - { - throw new NotFoundException(); - } - await _collectionRepository.DeleteUserAsync(collection.Id, organizationUserId); - await _eventService.LogOrganizationUserEventAsync(orgUser, Enums.EventType.OrganizationUser_Updated); - } -} diff --git a/src/Core/Services/Implementations/DeviceService.cs b/src/Core/Services/Implementations/DeviceService.cs index 165fab0237..ea6e77aa8c 100644 --- a/src/Core/Services/Implementations/DeviceService.cs +++ b/src/Core/Services/Implementations/DeviceService.cs @@ -3,8 +3,8 @@ using Bit.Core.Auth.Utilities; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.NotificationHub; using Bit.Core.Platform.Push; +using Bit.Core.Platform.PushRegistration; using Bit.Core.Repositories; using Bit.Core.Settings; @@ -29,9 +29,17 @@ public class DeviceService : IDeviceService _globalSettings = globalSettings; } - public async Task SaveAsync(WebPushRegistrationData webPush, Device device) + public async Task SaveAsync(WebPushRegistrationData webPush, Device device, IEnumerable organizationIds) { - await SaveAsync(new PushRegistrationData(webPush.Endpoint, webPush.P256dh, webPush.Auth), device); + await _pushRegistrationService.CreateOrUpdateRegistrationAsync( + new PushRegistrationData(webPush.Endpoint, webPush.P256dh, webPush.Auth), + device.Id.ToString(), + device.UserId.ToString(), + device.Identifier, + device.Type, + organizationIds, + _globalSettings.Installation.Id + ); } public async Task SaveAsync(Device device) diff --git a/src/Core/Services/Implementations/FeatureRoutedCacheService.cs b/src/Core/Services/Implementations/FeatureRoutedCacheService.cs new file mode 100644 index 0000000000..b6294a28f8 --- /dev/null +++ b/src/Core/Services/Implementations/FeatureRoutedCacheService.cs @@ -0,0 +1,152 @@ +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.Services.Implementations; + +/// +/// A feature-flagged routing service for application caching that bridges the gap between +/// scoped dependency injection (IFeatureService) and singleton services (cache implementations). +/// This service allows dynamic routing between IVCurrentInMemoryApplicationCacheService and +/// IVNextInMemoryApplicationCacheService based on the PM23845_VNextApplicationCache feature flag. +/// +/// +/// This service is necessary because: +/// - IFeatureService is registered as Scoped in the DI container +/// - IVNextInMemoryApplicationCacheService and IVCurrentInMemoryApplicationCacheService are registered as Singleton +/// - We need to evaluate feature flags at request time while maintaining singleton cache behavior +/// +/// The service acts as a scoped proxy that can access the scoped IFeatureService while +/// delegating actual cache operations to the appropriate singleton implementation. +/// +public class FeatureRoutedCacheService( + IFeatureService featureService, + IVNextInMemoryApplicationCacheService vNextInMemoryApplicationCacheService, + IVCurrentInMemoryApplicationCacheService inMemoryApplicationCacheService, + IApplicationCacheServiceBusMessaging serviceBusMessaging) + : IApplicationCacheService +{ + public async Task> GetOrganizationAbilitiesAsync() + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + return await vNextInMemoryApplicationCacheService.GetOrganizationAbilitiesAsync(); + } + + return await inMemoryApplicationCacheService.GetOrganizationAbilitiesAsync(); + } + + public async Task GetOrganizationAbilityAsync(Guid orgId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + return await vNextInMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId); + } + return await inMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId); + } + + public async Task> GetProviderAbilitiesAsync() + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + return await vNextInMemoryApplicationCacheService.GetProviderAbilitiesAsync(); + } + return await inMemoryApplicationCacheService.GetProviderAbilitiesAsync(); + } + + public async Task UpsertOrganizationAbilityAsync(Organization organization) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); + await serviceBusMessaging.NotifyOrganizationAbilityUpsertedAsync(organization); + } + else + { + await inMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + } + + public async Task UpsertProviderAbilityAsync(Provider provider) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider); + } + else + { + await inMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider); + } + } + + public async Task DeleteOrganizationAbilityAsync(Guid organizationId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId); + await serviceBusMessaging.NotifyOrganizationAbilityDeletedAsync(organizationId); + } + else + { + await inMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId); + } + } + + public async Task DeleteProviderAbilityAsync(Guid providerId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId); + await serviceBusMessaging.NotifyProviderAbilityDeletedAsync(providerId); + } + else + { + await inMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId); + } + + } + + public async Task BaseUpsertOrganizationAbilityAsync(Organization organization) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + else + { + // NOTE: This is a temporary workaround InMemoryServiceBusApplicationCacheService legacy implementation. + // Avoid using this approach in new code. + if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache) + { + await serviceBusCache.BaseUpsertOrganizationAbilityAsync(organization); + } + else + { + throw new InvalidOperationException($"Expected {nameof(inMemoryApplicationCacheService)} to be of type {nameof(InMemoryServiceBusApplicationCacheService)}"); + } + } + } + + public async Task BaseDeleteOrganizationAbilityAsync(Guid organizationId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId); + } + else + { + // NOTE: This is a temporary workaround InMemoryServiceBusApplicationCacheService legacy implementation. + // Avoid using this approach in new code. + if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache) + { + await serviceBusCache.BaseDeleteOrganizationAbilityAsync(organizationId); + } + else + { + throw new InvalidOperationException($"Expected {nameof(inMemoryApplicationCacheService)} to be of type {nameof(InMemoryServiceBusApplicationCacheService)}"); + } + } + } +} diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 20f6e3a0ab..75e0c78702 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -8,12 +8,14 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Mail; using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Mail; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.Mail; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Mail; +using Bit.Core.Models.Mail.Auth; using Bit.Core.Models.Mail.Billing; using Bit.Core.Models.Mail.FamiliesForEnterprise; using Bit.Core.Models.Mail.Provider; @@ -21,7 +23,10 @@ using Bit.Core.SecretsManager.Models.Mail; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; +using Core.Auth.Enums; using HandlebarsDotNet; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; namespace Bit.Core.Services; @@ -29,10 +34,13 @@ public class HandlebarsMailService : IMailService { private const string Namespace = "Bit.Core.MailTemplates.Handlebars"; private const string _utcTimeZoneDisplay = "UTC"; + private const string FailedTwoFactorAttemptCacheKeyFormat = "FailedTwoFactorAttemptEmail_{0}"; private readonly GlobalSettings _globalSettings; private readonly IMailDeliveryService _mailDeliveryService; private readonly IMailEnqueuingService _mailEnqueuingService; + private readonly IDistributedCache _distributedCache; + private readonly ILogger _logger; private readonly Dictionary> _templateCache = new(); private bool _registeredHelpersAndPartials = false; @@ -40,11 +48,15 @@ public class HandlebarsMailService : IMailService public HandlebarsMailService( GlobalSettings globalSettings, IMailDeliveryService mailDeliveryService, - IMailEnqueuingService mailEnqueuingService) + IMailEnqueuingService mailEnqueuingService, + IDistributedCache distributedCache, + ILogger logger) { _globalSettings = globalSettings; _mailDeliveryService = mailDeliveryService; _mailEnqueuingService = mailEnqueuingService; + _distributedCache = distributedCache; + _logger = logger; } public async Task SendVerifyEmailEmailAsync(string email, Guid userId, string token) @@ -166,14 +178,14 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true) + public async Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose) { var message = CreateDefaultMessage("Your Bitwarden Verification Code", email); var requestDateTime = DateTime.UtcNow; var model = new TwoFactorEmailTokenViewModel { Token = token, - EmailTotpAction = authentication ? "logging in" : "setting up two-step login", + EmailTotpAction = (purpose == TwoFactorEmailPurpose.Setup) ? "setting up two-step login" : "logging in", AccountEmail = accountEmail, TheDate = requestDateTime.ToLongDateString(), TheTime = requestDateTime.ToShortTimeString(), @@ -182,6 +194,9 @@ public class HandlebarsMailService : IMailService DeviceType = deviceType, WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, SiteName = _globalSettings.SiteName, + // We only want to remind users to set up 2FA if they're getting a new device verification email. + // For login with 2FA, and setup of 2FA, we do not want to show the reminder because users are already doing so. + DisplayTwoFactorReminder = purpose == TwoFactorEmailPurpose.NewDeviceVerification }; await AddMessageContentAsync(message, "Auth.TwoFactorEmail", model); message.MetaData.Add("SendGridBypassListManagement", true); @@ -189,6 +204,62 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendSendEmailOtpEmailAsync(string email, string token, string subject) + { + var message = CreateDefaultMessage(subject, email); + var requestDateTime = DateTime.UtcNow; + var model = new DefaultEmailOtpViewModel + { + Token = token, + TheDate = requestDateTime.ToLongDateString(), + TheTime = requestDateTime.ToShortTimeString(), + TimeZone = _utcTimeZoneDisplay, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + }; + await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model); + message.MetaData.Add("SendGridBypassListManagement", true); + // TODO - PM-25380 change to string constant + message.Category = "SendEmailOtp"; + await _mailDeliveryService.SendEmailAsync(message); + } + + public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip) + { + // Check if we've sent this email within the last hour + var cacheKey = string.Format(FailedTwoFactorAttemptCacheKeyFormat, email); + var cachedValue = await _distributedCache.GetAsync(cacheKey); + + if (cachedValue != null) + { + // Email was already sent within the last hour, skip sending + return; + } + + var message = CreateDefaultMessage("Failed two-step login attempt detected", email); + var model = new FailedAuthAttemptModel() + { + TheDate = utcNow.ToLongDateString(), + TheTime = utcNow.ToShortTimeString(), + TimeZone = _utcTimeZoneDisplay, + IpAddress = ip, + AffectedEmail = email, + TwoFactorType = failedType, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash + + }; + await AddMessageContentAsync(message, "Auth.FailedTwoFactorAttempt", model); + message.Category = "FailedTwoFactorAttempt"; + await _mailDeliveryService.SendEmailAsync(message); + + // Set cache entry with 1 hour expiration to prevent sending again + var cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) + }; + await _distributedCache.SetAsync(cacheKey, [1], cacheOptions); + } + public async Task SendMasterPasswordHintEmailAsync(string email, string hint) { var message = CreateDefaultMessage("Your Master Password Hint", email); @@ -284,21 +355,35 @@ public class HandlebarsMailService : IMailService public async Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo) { - MailQueueMessage CreateMessage(string email, object model) - { - var message = CreateDefaultMessage($"Join {orgInvitesInfo.OrganizationName}", email); - return new MailQueueMessage(message, "OrganizationUserInvited", model); - } - var messageModels = orgInvitesInfo.OrgUserTokenPairs.Select(orgUserTokenPair => { Debug.Assert(orgUserTokenPair.OrgUser.Email is not null); - var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo( - orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings); + + var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo(orgInvitesInfo, orgUserTokenPair.OrgUser, + orgUserTokenPair.Token, _globalSettings); + return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel); }); await EnqueueMailAsync(messageModels); + return; + + MailQueueMessage CreateMessage(string email, OrganizationUserInvitedViewModel model) + { + ArgumentNullException.ThrowIfNull(model); + + var subject = model! switch + { + { IsFreeOrg: true, OrgUserHasExistingUser: true } => "You have been invited to a Bitwarden Organization", + { IsFreeOrg: true, OrgUserHasExistingUser: false } => "You have been invited to Bitwarden Password Manager", + { IsFreeOrg: false, OrgUserHasExistingUser: true } => $"{model.OrganizationName} invited you to their Bitwarden organization", + { IsFreeOrg: false, OrgUserHasExistingUser: false } => $"{model.OrganizationName} set up a Bitwarden account for you" + }; + + var message = CreateDefaultMessage(subject, email); + + return new MailQueueMessage(message, "OrganizationUserInvited", model); + } } public async Task SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(string organizationName, string email) @@ -411,6 +496,33 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendProviderInvoiceUpcoming( + IEnumerable emails, + decimal amount, + DateTime dueDate, + List items, + string? collectionMethod = null, + bool hasPaymentMethod = true, + string? paymentMethodDescription = null) + { + var message = CreateDefaultMessage("Your upcoming Bitwarden invoice", emails); + var model = new InvoiceUpcomingViewModel + { + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + AmountDue = amount, + DueDate = dueDate, + Items = items, + MentionInvoices = false, + CollectionMethod = collectionMethod, + HasPaymentMethod = hasPaymentMethod, + PaymentMethodDescription = paymentMethodDescription + }; + await AddMessageContentAsync(message, "ProviderInvoiceUpcoming", model); + message.Category = "ProviderInvoiceUpcoming"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices) { var message = CreateDefaultMessage("Payment Failed", email); @@ -492,7 +604,7 @@ public class HandlebarsMailService : IMailService SiteName = _globalSettings.SiteName, DeviceType = deviceType, TheDate = timestamp.ToLongDateString(), - TheTime = timestamp.ToShortTimeString(), + TheTime = timestamp.ToString("hh:mm:ss tt"), TimeZone = _utcTimeZoneDisplay, IpAddress = ip }; @@ -600,6 +712,12 @@ public class HandlebarsMailService : IMailService private async Task ReadSourceAsync(string templateName) { + var diskSource = await ReadSourceFromDiskAsync(templateName); + if (!string.IsNullOrWhiteSpace(diskSource)) + { + return diskSource; + } + var assembly = typeof(HandlebarsMailService).GetTypeInfo().Assembly; var fullTemplateName = $"{Namespace}.{templateName}.hbs"; if (!assembly.GetManifestResourceNames().Any(f => f == fullTemplateName)) @@ -613,6 +731,42 @@ public class HandlebarsMailService : IMailService } } + private async Task ReadSourceFromDiskAsync(string templateName) + { + if (!_globalSettings.SelfHosted) + { + return null; + } + try + { + var templateFileSuffix = ".html"; + if (templateName.EndsWith(".txt")) + { + templateFileSuffix = ".txt"; + } + else if (!templateName.EndsWith(".html")) + { + // unexpected suffix + return null; + } + var suffixPosition = templateName.LastIndexOf(templateFileSuffix); + var templateNameNoSuffix = templateName.Substring(0, suffixPosition); + var templatePathNoSuffix = templateNameNoSuffix.Replace(".", "/"); + var diskPath = $"{_globalSettings.MailTemplateDirectory}/{templatePathNoSuffix}{templateFileSuffix}.hbs"; + var directory = Path.GetDirectoryName(diskPath); + if (Directory.Exists(directory) && File.Exists(diskPath)) + { + var fileContents = await File.ReadAllTextAsync(diskPath); + return fileContents; + } + } + catch (Exception e) + { + _logger.LogError(e, "Failed to read mail template from disk."); + } + return null; + } + private async Task RegisterHelpersAndPartialsAsync() { if (_registeredHelpersAndPartials) @@ -641,6 +795,8 @@ public class HandlebarsMailService : IMailService Handlebars.RegisterTemplate("SecurityTasksHtmlLayout", securityTasksHtmlLayoutSource); var securityTasksTextLayoutSource = await ReadSourceAsync("Layouts.SecurityTasks.text"); Handlebars.RegisterTemplate("SecurityTasksTextLayout", securityTasksTextLayoutSource); + var providerFullHtmlLayoutSource = await ReadSourceAsync("Layouts.ProviderFull.html"); + Handlebars.RegisterTemplate("ProviderFull", providerFullHtmlLayoutSource); Handlebars.RegisterHelper("date", (writer, context, parameters) => { @@ -796,6 +952,19 @@ public class HandlebarsMailService : IMailService writer.WriteSafeString(string.Empty); } }); + + // Equality comparison helper for conditional templates. + Handlebars.RegisterHelper("eq", (context, arguments) => + { + if (arguments.Length != 2) + { + return false; + } + + var value1 = arguments[0]?.ToString(); + var value2 = arguments[1]?.ToString(); + return string.Equals(value1, value2, StringComparison.OrdinalIgnoreCase); + }); } public async Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token) diff --git a/src/Core/Services/Implementations/I18nService.cs b/src/Core/Services/Implementations/I18nService.cs index 25e2f8e5dc..3d8737dbaa 100644 --- a/src/Core/Services/Implementations/I18nService.cs +++ b/src/Core/Services/Implementations/I18nService.cs @@ -20,17 +20,19 @@ public class I18nService : II18nService return _localizer[key]; } - public LocalizedString GetLocalizedHtmlString(string key, params object[] args) + public LocalizedString GetLocalizedHtmlString(string key, params object?[] args) { +#nullable disable // IStringLocalizer does actually support null args, it is annotated incorrectly: https://github.com/dotnet/aspnetcore/issues/44251 return _localizer[key, args]; +#nullable enable } - public string Translate(string key, params object[] args) + public string Translate(string key, params object?[] args) { return string.Format(GetLocalizedHtmlString(key).ToString(), args); } - public string T(string key, params object[] args) + public string T(string key, params object?[] args) { return Translate(key, args); } diff --git a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs index 0fde6d8906..4062162701 100644 --- a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs +++ b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs @@ -1,4 +1,8 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; @@ -7,7 +11,7 @@ using Bit.Core.Repositories; namespace Bit.Core.Services; -public class InMemoryApplicationCacheService : IApplicationCacheService +public class InMemoryApplicationCacheService : IVCurrentInMemoryApplicationCacheService { private readonly IOrganizationRepository _organizationRepository; private readonly IProviderRepository _providerRepository; diff --git a/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs b/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs index da70ccd2fd..b856bfa749 100644 --- a/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs +++ b/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs @@ -8,9 +8,8 @@ using Bit.Core.Utilities; namespace Bit.Core.Services; -public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCacheService, IApplicationCacheService +public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCacheService { - private readonly ServiceBusClient _serviceBusClient; private readonly ServiceBusSender _topicMessageSender; private readonly string _subName; @@ -21,7 +20,7 @@ public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCach : base(organizationRepository, providerRepository) { _subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings); - _serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString); + _topicMessageSender = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString).CreateSender(globalSettings.ServiceBus.ApplicationCacheTopicName); } diff --git a/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs b/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs index 69b8a94e5a..f118146cb1 100644 --- a/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs +++ b/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs @@ -1,5 +1,8 @@ -using Bit.Core.Context; -using Bit.Core.Identity; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Identity; +using Bit.Core.Context; using Bit.Core.Settings; using Bit.Core.Utilities; using LaunchDarkly.Logging; diff --git a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs b/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs index 2ebc7492f7..04eda42d22 100644 --- a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs +++ b/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs @@ -1,10 +1,10 @@ -using System.Security.Cryptography.X509Certificates; -using Bit.Core.Platform.X509ChainCustomization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + using Bit.Core.Settings; using Bit.Core.Utilities; using MailKit.Net.Smtp; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using MimeKit; namespace Bit.Core.Services; @@ -13,14 +13,12 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService { private readonly GlobalSettings _globalSettings; private readonly ILogger _logger; - private readonly X509ChainOptions _x509ChainOptions; private readonly string _replyDomain; private readonly string _replyEmail; public MailKitSmtpMailDeliveryService( GlobalSettings globalSettings, - ILogger logger, - IOptions x509ChainOptions) + ILogger logger) { if (globalSettings.Mail.Smtp?.Host == null) { @@ -41,7 +39,6 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService _globalSettings = globalSettings; _logger = logger; - _x509ChainOptions = x509ChainOptions.Value; } public async Task SendEmailAsync(Models.Mail.MailMessage message) @@ -86,13 +83,6 @@ public class MailKitSmtpMailDeliveryService : IMailDeliveryService { client.ServerCertificateValidationCallback = (s, c, h, e) => true; } - else if (_x509ChainOptions.TryGetCustomRemoteCertificateValidationCallback(out var callback)) - { - client.ServerCertificateValidationCallback = (sender, cert, chain, errors) => - { - return callback(new X509Certificate2(cert), chain, errors); - }; - } if (!_globalSettings.Mail.Smtp.StartTls && !_globalSettings.Mail.Smtp.Ssl && _globalSettings.Mail.Smtp.Port == 25) diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index fd9f212ee7..4863baf73e 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -1,5 +1,9 @@ -using Bit.Core.Models.BitStripe; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.BitStripe; using Stripe; +using Stripe.Tax; namespace Bit.Core.Services; @@ -19,6 +23,8 @@ public class StripeAdapter : IStripeAdapter private readonly Stripe.SetupIntentService _setupIntentService; private readonly Stripe.TestHelpers.TestClockService _testClockService; private readonly CustomerBalanceTransactionService _customerBalanceTransactionService; + private readonly Stripe.Tax.RegistrationService _taxRegistrationService; + private readonly CalculationService _calculationService; public StripeAdapter() { @@ -36,6 +42,8 @@ public class StripeAdapter : IStripeAdapter _setupIntentService = new SetupIntentService(); _testClockService = new Stripe.TestHelpers.TestClockService(); _customerBalanceTransactionService = new CustomerBalanceTransactionService(); + _taxRegistrationService = new Stripe.Tax.RegistrationService(); + _calculationService = new CalculationService(); } public Task CustomerCreateAsync(Stripe.CustomerCreateOptions options) @@ -205,6 +213,11 @@ public class StripeAdapter : IStripeAdapter return _taxIdService.DeleteAsync(customerId, taxIdId); } + public Task> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null) + { + return _taxRegistrationService.ListAsync(options); + } + public Task> ChargeListAsync(Stripe.ChargeListOptions options) { return _chargeService.ListAsync(options); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index bdd558df52..5b68906d8a 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1,17 +1,17 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Entities.Provider; +// FIXME: Update this file to be null safe and then delete the line below + +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Responses; using Bit.Core.Billing.Tax.Services; -using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -19,7 +19,6 @@ using Bit.Core.Models.BitStripe; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Settings; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Stripe; using PaymentMethod = Stripe.PaymentMethod; @@ -39,8 +38,6 @@ public class StripePaymentService : IPaymentService private readonly IFeatureService _featureService; private readonly ITaxService _taxService; private readonly IPricingClient _pricingClient; - private readonly IAutomaticTaxFactory _automaticTaxFactory; - private readonly IAutomaticTaxStrategy _personalUseTaxStrategy; public StripePaymentService( ITransactionRepository transactionRepository, @@ -50,9 +47,7 @@ public class StripePaymentService : IPaymentService IGlobalSettings globalSettings, IFeatureService featureService, ITaxService taxService, - IPricingClient pricingClient, - IAutomaticTaxFactory automaticTaxFactory, - [FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy personalUseTaxStrategy) + IPricingClient pricingClient) { _transactionRepository = transactionRepository; _logger = logger; @@ -62,8 +57,6 @@ public class StripePaymentService : IPaymentService _featureService = featureService; _taxService = taxService; _pricingClient = pricingClient; - _automaticTaxFactory = automaticTaxFactory; - _personalUseTaxStrategy = personalUseTaxStrategy; } private async Task ChangeOrganizationSponsorship( @@ -133,69 +126,17 @@ public class StripePaymentService : IPaymentService if (subscriptionUpdate is CompleteSubscriptionUpdate) { - var setNonUSBusinessUseToReverseCharge = - _featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); - - if (setNonUSBusinessUseToReverseCharge) - { - if (sub.Customer is - { - Address.Country: not "US", - TaxExempt: not StripeConstants.TaxExempt.Reverse - }) + if (sub.Customer is { - await _stripeAdapter.CustomerUpdateAsync(sub.CustomerId, - new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); - } - - subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; - } - else if (sub.Customer.HasRecognizedTaxLocation()) + Address.Country: not Constants.CountryAbbreviations.UnitedStates, + TaxExempt: not StripeConstants.TaxExempt.Reverse + }) { - switch (subscriber) - { - case User: - { - subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; - break; - } - case Organization: - { - if (sub.Customer.Address.Country == "US") - { - subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; - } - else - { - var familyPriceIds = (await Task.WhenAll( - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), - _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually))) - .Select(plan => plan.PasswordManager.StripePlanId); - - var updateIsForPersonalUse = updatedItemOptions - .Select(option => option.Price) - .Intersect(familyPriceIds) - .Any(); - - subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = updateIsForPersonalUse || sub.Customer.TaxIds.Any() - }; - } - - break; - } - case Provider: - { - subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = sub.Customer.Address.Country == "US" || - sub.Customer.TaxIds.Any() - }; - break; - } - } + await _stripeAdapter.CustomerUpdateAsync(sub.CustomerId, + new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); } + + subUpdateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } if (!subscriptionUpdate.UpdateNeeded(sub)) @@ -965,7 +906,7 @@ public class StripePaymentService : IPaymentService new() { Quantity = 1, - Plan = "premium-annually" + Plan = StripeConstants.Prices.PremiumAnnually }, new() @@ -1037,8 +978,6 @@ public class StripePaymentService : IPaymentService } } - _personalUseTaxStrategy.SetInvoiceCreatePreviewOptions(options); - try { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); @@ -1202,9 +1141,12 @@ public class StripePaymentService : IPaymentService } } - var automaticTaxFactoryParameters = new AutomaticTaxFactoryParameters(parameters.PasswordManager.Plan); - var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxFactoryParameters); - automaticTaxStrategy.SetInvoiceCreatePreviewOptions(options); + options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; + if (parameters.PasswordManager.Plan.IsBusinessProductTierType() && + parameters.TaxInformation.Country != Constants.CountryAbbreviations.UnitedStates) + { + options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse; + } try { diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index fe5a064c44..a36b9e37cc 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -1,5 +1,6 @@ -using System.ComponentModel.DataAnnotations; -using System.Reflection; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + using System.Security.Claims; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; @@ -15,6 +16,7 @@ using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; @@ -42,8 +44,6 @@ namespace Bit.Core.Services; public class UserService : UserManager, IUserService { - private const string PremiumPlanId = "premium-annually"; - private readonly IUserRepository _userRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; @@ -337,52 +337,6 @@ public class UserService : UserManager, IUserService await _mailService.SendMasterPasswordHintEmailAsync(email, user.MasterPasswordHint); } - public async Task SendTwoFactorEmailAsync(User user, bool authentication = true) - { - var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); - if (provider == null || provider.MetaData == null || !provider.MetaData.TryGetValue("Email", out var emailValue)) - { - throw new ArgumentNullException("No email."); - } - - var email = ((string)emailValue).ToLowerInvariant(); - var token = await base.GenerateTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(TwoFactorProviderType.Email)); - - var deviceType = _currentContext.DeviceType?.GetType().GetMember(_currentContext.DeviceType?.ToString()) - .FirstOrDefault()?.GetCustomAttribute()?.GetName() ?? "Unknown Browser"; - - await _mailService.SendTwoFactorEmailAsync( - email, user.Email, token, _currentContext.IpAddress, deviceType, authentication); - } - - public async Task SendNewDeviceVerificationEmailAsync(User user) - { - ArgumentNullException.ThrowIfNull(user); - - var token = await base.GenerateUserTokenAsync(user, TokenOptions.DefaultEmailProvider, - "otp:" + user.Email); - - var deviceType = _currentContext.DeviceType?.GetType().GetMember(_currentContext.DeviceType?.ToString()) - .FirstOrDefault()?.GetCustomAttribute()?.GetName() ?? "Unknown Browser"; - - await _mailService.SendTwoFactorEmailAsync( - user.Email, user.Email, token, _currentContext.IpAddress, deviceType); - } - - public async Task VerifyTwoFactorEmailAsync(User user, string token) - { - var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); - if (provider == null || provider.MetaData == null || !provider.MetaData.TryGetValue("Email", out var emailValue)) - { - throw new ArgumentNullException("No email."); - } - - var email = ((string)emailValue).ToLowerInvariant(); - return await base.VerifyTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), token); - } - public async Task StartWebAuthnRegistrationAsync(User user) { var providers = user.GetTwoFactorProviders(); @@ -823,39 +777,6 @@ public class UserService : UserManager, IUserService return IdentityResult.Success; } - public async Task ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, - string key, KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (await CheckPasswordAsync(user, masterPassword)) - { - var result = await UpdatePasswordHash(user, newMasterPassword); - if (!result.Succeeded) - { - return result; - } - - var now = DateTime.UtcNow; - user.RevisionDate = user.AccountRevisionDate = now; - user.LastKdfChangeDate = now; - user.Key = key; - user.Kdf = kdf; - user.KdfIterations = kdfIterations; - user.KdfMemory = kdfMemory; - user.KdfParallelism = kdfParallelism; - await _userRepository.ReplaceAsync(user); - await _pushService.PushLogOutAsync(user.Id); - return IdentityResult.Success; - } - - Logger.LogWarning("Change KDF failed for user {userId}.", user.Id); - return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); - } - public async Task RefreshSecurityStampAsync(User user, string secret) { if (user == null) @@ -909,39 +830,6 @@ public class UserService : UserManager, IUserService } } - /// - /// To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175. - /// - [Obsolete("Two Factor recovery is handled in the TwoFactorAuthenticationValidator.")] - public async Task RecoverTwoFactorAsync(string email, string secret, string recoveryCode) - { - var user = await _userRepository.GetByEmailAsync(email); - if (user == null) - { - // No user exists. Do we want to send an email telling them this in the future? - return false; - } - - if (!await VerifySecretAsync(user, secret)) - { - return false; - } - - if (!CoreHelpers.FixedTimeEquals(user.TwoFactorRecoveryCode, recoveryCode)) - { - return false; - } - - user.TwoFactorProviders = null; - user.TwoFactorRecoveryCode = CoreHelpers.SecureRandomString(32, upper: false, special: false); - await SaveUserAsync(user); - await _mailService.SendRecoverTwoFactorEmail(user.Email, DateTime.UtcNow, _currentContext.IpAddress); - await _eventService.LogUserEventAsync(user.Id, EventType.User_Recovered2fa); - await CheckPoliciesOnTwoFactorRemovalAsync(user); - - return true; - } - public async Task RecoverTwoFactorAsync(User user, string recoveryCode) { if (!CoreHelpers.FixedTimeEquals( @@ -1007,7 +895,7 @@ public class UserService : UserManager, IUserService if (_globalSettings.SelfHosted) { - user.MaxStorageGb = 10240; // 10 TB + user.MaxStorageGb = Constants.SelfHostedMaxStorageGb; user.LicenseKey = license.LicenseKey; user.PremiumExpirationDate = license.Expires; } @@ -1066,7 +954,7 @@ public class UserService : UserManager, IUserService user.Premium = license.Premium; user.RevisionDate = DateTime.UtcNow; - user.MaxStorageGb = _globalSettings.SelfHosted ? 10240 : license.MaxStorageGb; // 10 TB + user.MaxStorageGb = _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : license.MaxStorageGb; user.LicenseKey = license.LicenseKey; user.PremiumExpirationDate = license.Expires; await SaveUserAsync(user); @@ -1454,20 +1342,6 @@ public class UserService : UserManager, IUserService return isVerified; } - public async Task ResendNewDeviceVerificationEmail(string email, string secret) - { - var user = await _userRepository.GetByEmailAsync(email); - if (user == null) - { - return; - } - - if (await VerifySecretAsync(user, secret)) - { - await SendNewDeviceVerificationEmailAsync(user); - } - } - public async Task ActiveNewDeviceVerificationException(Guid userId) { var cacheKey = string.Format(AuthConstants.NewDeviceVerificationExceptionCacheKeyFormat, userId.ToString()); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 26858911a8..7ec05bb1f9 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -3,11 +3,13 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Mail; using Bit.Core.Vault.Models.Data; +using Core.Auth.Enums; namespace Bit.Core.Services; @@ -86,7 +88,17 @@ public class NoopMailService : IMailService public Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email) => Task.CompletedTask; - public Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, bool authentication = true) + public Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose) + { + return Task.FromResult(0); + } + + public Task SendSendEmailOtpEmailAsync(string email, string token, string subject) + { + return Task.FromResult(0); + } + + public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip) { return Task.FromResult(0); } @@ -125,6 +137,15 @@ public class NoopMailService : IMailService List items, bool mentionInvoices) => Task.FromResult(0); + public Task SendProviderInvoiceUpcoming( + IEnumerable emails, + decimal amount, + DateTime dueDate, + List items, + string? collectionMethod = null, + bool hasPaymentMethod = true, + string? paymentMethodDescription = null) => Task.FromResult(0); + public Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices) { return Task.FromResult(0); diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 7a794ec3f6..250daf0007 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -1,10 +1,14 @@ -using Bit.Core.Auth.Settings; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Settings; using Bit.Core.Settings.LoggingSettings; namespace Bit.Core.Settings; public class GlobalSettings : IGlobalSettings { + private string _mailTemplateDirectory; private string _logDirectory; private string _licenseDirectory; @@ -34,6 +38,11 @@ public class GlobalSettings : IGlobalSettings get => BuildDirectory(_licenseDirectory, "/core/licenses"); set => _licenseDirectory = value; } + public virtual string MailTemplateDirectory + { + get => BuildDirectory(_mailTemplateDirectory, "/mail-templates"); + set => _mailTemplateDirectory = value; + } public string LicenseCertificatePassword { get; set; } public virtual string PushRelayBaseUri { get; set; } public virtual string InternalIdentityKey { get; set; } @@ -86,9 +95,15 @@ public class GlobalSettings : IGlobalSettings public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings(); public virtual IPhishingDomainSettings PhishingDomain { get; set; } = new PhishingDomainSettings(); + public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5; public virtual bool EnableEmailVerification { get; set; } public virtual string KdfDefaultHashKey { get; set; } + /// + /// This Hash Key is used to prevent enumeration attacks against the Send Access feature. + /// + public virtual string SendDefaultHashKey { get; set; } public virtual string PricingUri { get; set; } + public virtual Fido2Settings Fido2 { get; set; } = new Fido2Settings(); public string BuildExternalUri(string explicitValue, string name) { @@ -284,6 +299,8 @@ public class GlobalSettings : IGlobalSettings { public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings(); public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings(); + public int IntegrationCacheRefreshIntervalMinutes { get; set; } = 10; + public int MaxRetries { get; set; } = 3; public class AzureServiceBusSettings { @@ -291,12 +308,18 @@ public class GlobalSettings : IGlobalSettings private string _eventTopicName; private string _integrationTopicName; - public int MaxRetries { get; set; } = 3; + public virtual int DefaultMaxConcurrentCalls { get; set; } = 1; + public virtual int DefaultPrefetchCount { get; set; } = 0; + public virtual string EventRepositorySubscriptionName { get; set; } = "events-write-subscription"; public virtual string SlackEventSubscriptionName { get; set; } = "events-slack-subscription"; public virtual string SlackIntegrationSubscriptionName { get; set; } = "integration-slack-subscription"; public virtual string WebhookEventSubscriptionName { get; set; } = "events-webhook-subscription"; public virtual string WebhookIntegrationSubscriptionName { get; set; } = "integration-webhook-subscription"; + public virtual string HecEventSubscriptionName { get; set; } = "events-hec-subscription"; + public virtual string HecIntegrationSubscriptionName { get; set; } = "integration-hec-subscription"; + public virtual string DatadogEventSubscriptionName { get; set; } = "events-datadog-subscription"; + public virtual string DatadogIntegrationSubscriptionName { get; set; } = "integration-datadog-subscription"; public string ConnectionString { @@ -325,7 +348,6 @@ public class GlobalSettings : IGlobalSettings private string _eventExchangeName; private string _integrationExchangeName; - public int MaxRetries { get; set; } = 3; public int RetryTiming { get; set; } = 30000; // 30s public bool UseDelayPlugin { get; set; } = false; public virtual string EventRepositoryQueueName { get; set; } = "events-write-queue"; @@ -336,6 +358,12 @@ public class GlobalSettings : IGlobalSettings public virtual string WebhookEventsQueueName { get; set; } = "events-webhook-queue"; public virtual string WebhookIntegrationQueueName { get; set; } = "integration-webhook-queue"; public virtual string WebhookIntegrationRetryQueueName { get; set; } = "integration-webhook-retry-queue"; + public virtual string HecEventsQueueName { get; set; } = "events-hec-queue"; + public virtual string HecIntegrationQueueName { get; set; } = "integration-hec-queue"; + public virtual string HecIntegrationRetryQueueName { get; set; } = "integration-hec-retry-queue"; + public virtual string DatadogEventsQueueName { get; set; } = "events-datadog-queue"; + public virtual string DatadogIntegrationQueueName { get; set; } = "integration-datadog-queue"; + public virtual string DatadogIntegrationRetryQueueName { get; set; } = "integration-datadog-retry-queue"; public string HostName { @@ -454,6 +482,35 @@ public class GlobalSettings : IGlobalSettings public string RedisConnectionString { get; set; } public string CosmosConnectionString { get; set; } public string LicenseKey { get; set; } = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzM0NTY2NDAwLCJleHAiOjE3NjQ5NzkyMDAsImNvbXBhbnlfbmFtZSI6IkJpdHdhcmRlbiBJbmMuIiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiU3RhcnRlciIsImlkIjoiNjg3OCIsImZlYXR1cmUiOlsiaXN2IiwidW5saW1pdGVkX2NsaWVudHMiXSwicHJvZHVjdCI6IkJpdHdhcmRlbiJ9.TYc88W_t2t0F2AJV3rdyKwGyQKrKFriSAzm1tWFNHNR9QizfC-8bliGdT4Wgeie-ynCXs9wWaF-sKC5emg--qS7oe2iIt67Qd88WS53AwgTvAddQRA4NhGB1R7VM8GAikLieSos-DzzwLYRgjZdmcsprItYGSJuY73r-7-F97ta915majBytVxGF966tT9zF1aYk0bA8FS6DcDYkr5f7Nsy8daS_uIUAgNa_agKXtmQPqKujqtUb6rgWEpSp4OcQcG-8Dpd5jHqoIjouGvY-5LTgk5WmLxi_m-1QISjxUJrUm-UGao3_VwV5KFGqYrz8csdTl-HS40ihWcsWnrV0ug"; + /// + /// Sliding lifetime of a refresh token in seconds. + /// + /// Each time the refresh token is used before the sliding window ends, its lifetime is extended by another SlidingRefreshTokenLifetimeSeconds. + /// + /// If AbsoluteRefreshTokenLifetimeSeconds > 0, the sliding extensions are bounded by the absolute maximum lifetime. + /// If SlidingRefreshTokenLifetimeSeconds = 0, sliding mode is invalid (refresh tokens cannot be used). + /// + public int? SlidingRefreshTokenLifetimeSeconds { get; set; } + /// + /// Maximum lifetime of a refresh token in seconds. + /// + /// Token cannot be refreshed by any means beyond the absolute refresh expiration. + /// + /// When setting this value to 0, the following effect applies: + /// If ApplyAbsoluteExpirationOnRefreshToken is set to true, the behavior is the same as when no refresh tokens are used. + /// If ApplyAbsoluteExpirationOnRefreshToken is set to false, refresh tokens only expire after the SlidingRefreshTokenLifetimeSeconds has passed. + /// + public int? AbsoluteRefreshTokenLifetimeSeconds { get; set; } + /// + /// Controls whether refresh tokens expire absolutely or on a sliding window basis. + /// + /// Absolute: + /// Token expires at a fixed point in time (defined by AbsoluteRefreshTokenLifetimeSeconds). Usage does not extend lifetime. + /// + /// Sliding(default): + /// Token lifetime is renewed on each use, by the amount in SlidingRefreshTokenLifetimeSeconds. Extensions stop once AbsoluteRefreshTokenLifetimeSeconds is reached (if set > 0). + /// + public bool ApplyAbsoluteExpirationOnRefreshToken { get; set; } = false; } public class DataProtectionSettings @@ -716,4 +773,9 @@ public class GlobalSettings : IGlobalSettings { public string VapidPublicKey { get; set; } } + + public class Fido2Settings + { + public HashSet Origins { get; set; } + } } diff --git a/src/Core/Tokens/DataProtectorTokenFactory.cs b/src/Core/Tokens/DataProtectorTokenFactory.cs index 1f8c2254f3..26cf517dfc 100644 --- a/src/Core/Tokens/DataProtectorTokenFactory.cs +++ b/src/Core/Tokens/DataProtectorTokenFactory.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.DataProtection; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; namespace Bit.Core.Tokens; diff --git a/src/Core/Tokens/Tokenable.cs b/src/Core/Tokens/Tokenable.cs index a145e64bb5..860982228f 100644 --- a/src/Core/Tokens/Tokenable.cs +++ b/src/Core/Tokens/Tokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; namespace Bit.Core.Tokens; diff --git a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs index 829eedc34d..c7f7e3aff7 100644 --- a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs +++ b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; diff --git a/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs index f41c62f409..82232cb757 100644 --- a/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs +++ b/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Exceptions; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Exceptions; using Bit.Core.Platform.Push; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; diff --git a/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs index 804200a05f..9655d155e6 100644 --- a/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs +++ b/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Exceptions; using Bit.Core.Platform.Push; using Bit.Core.Tools.Entities; diff --git a/src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs b/src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs index ee54ffd6b6..748a4e1d07 100644 --- a/src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs +++ b/src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs @@ -1,4 +1,7 @@ -using Azure.Storage.Blobs; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Azure.Storage.Sas; using Bit.Core.Enums; diff --git a/src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs b/src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs index a6b3fb0faf..6722e4f46b 100644 --- a/src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs +++ b/src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.Settings; using Bit.Core.Tools.Entities; diff --git a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs index f1e8855def..c545c8b35f 100644 --- a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs +++ b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -122,7 +125,7 @@ public class SendValidationService : ISendValidationService { // Users that get access to file storage/premium from their organization get the default // 1 GB max storage. - short limit = _globalSettings.SelfHosted ? (short)10240 : (short)1; + short limit = _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1; storageBytesRemaining = user.StorageBytesRemaining(limit); } } diff --git a/src/Core/Tools/Services/NoopImplementations/NoopSendFileStorageService.cs b/src/Core/Tools/Services/NoopImplementations/NoopSendFileStorageService.cs index 16c20e521e..3aeecde4fd 100644 --- a/src/Core/Tools/Services/NoopImplementations/NoopSendFileStorageService.cs +++ b/src/Core/Tools/Services/NoopImplementations/NoopSendFileStorageService.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.Tools.Entities; namespace Bit.Core.Tools.Services; diff --git a/src/Core/Utilities/AssemblyHelpers.cs b/src/Core/Utilities/AssemblyHelpers.cs index a00e108515..0cc01efdf3 100644 --- a/src/Core/Utilities/AssemblyHelpers.cs +++ b/src/Core/Utilities/AssemblyHelpers.cs @@ -1,4 +1,7 @@ -using System.Reflection; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Reflection; namespace Bit.Core.Utilities; diff --git a/src/Core/Utilities/BitPayClient.cs b/src/Core/Utilities/BitPayClient.cs index 35a078998d..cf241d5723 100644 --- a/src/Core/Utilities/BitPayClient.cs +++ b/src/Core/Utilities/BitPayClient.cs @@ -1,4 +1,7 @@ -using Bit.Core.Settings; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Settings; namespace Bit.Core.Utilities; diff --git a/src/Core/Utilities/BulkAuthorizationHandler.cs b/src/Core/Utilities/BulkAuthorizationHandler.cs index c427a426e0..bb5764c53c 100644 --- a/src/Core/Utilities/BulkAuthorizationHandler.cs +++ b/src/Core/Utilities/BulkAuthorizationHandler.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Authorization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Authorization; namespace Bit.Core.Utilities; diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 14a2ec35e5..5acdc63489 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -16,12 +16,12 @@ using Azure.Storage.Queues.Models; using Bit.Core.AdminConsole.Context; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Billing.Enums; using Bit.Core.Context; using Bit.Core.Entities; -using Bit.Core.Identity; using Bit.Core.Settings; -using IdentityModel; +using Duende.IdentityModel; using Microsoft.AspNetCore.DataProtection; using MimeKit; @@ -41,9 +41,12 @@ public static class CoreHelpers }; /// - /// Generate sequential Guid for Sql Server. - /// ref: https://github.com/nhibernate/nhibernate-core/blob/master/src/NHibernate/Id/GuidCombGenerator.cs + /// Generate a sequential Guid for Sql Server. This prevents SQL Server index fragmentation by incorporating timestamp + /// information for sequential ordering. This should be preferred to for any database IDs. /// + /// + /// ref: https://github.com/nhibernate/nhibernate-core/blob/master/src/NHibernate/Id/GuidCombGenerator.cs + /// /// A comb Guid. public static Guid GenerateComb() => GenerateComb(Guid.NewGuid(), DateTime.UtcNow); diff --git a/src/Core/Utilities/CustomRedisProcessingStrategy.cs b/src/Core/Utilities/CustomRedisProcessingStrategy.cs index 12a48e400f..b7125bfc79 100644 --- a/src/Core/Utilities/CustomRedisProcessingStrategy.cs +++ b/src/Core/Utilities/CustomRedisProcessingStrategy.cs @@ -1,4 +1,7 @@ -using AspNetCoreRateLimit; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AspNetCoreRateLimit; using AspNetCoreRateLimit.Redis; using Bit.Core.Settings; using Microsoft.Extensions.Caching.Memory; diff --git a/src/Core/Utilities/DeviceTypes.cs b/src/Core/Utilities/DeviceTypes.cs index f42d1d9a2b..57dbe29b3d 100644 --- a/src/Core/Utilities/DeviceTypes.cs +++ b/src/Core/Utilities/DeviceTypes.cs @@ -45,6 +45,7 @@ public static class DeviceTypes DeviceType.IEBrowser, DeviceType.SafariBrowser, DeviceType.VivaldiBrowser, + DeviceType.DuckDuckGoBrowser, DeviceType.UnknownBrowser ]; diff --git a/src/Core/Utilities/DistributedCacheExtensions.cs b/src/Core/Utilities/DistributedCacheExtensions.cs index 28282b6a47..2459faeb56 100644 --- a/src/Core/Utilities/DistributedCacheExtensions.cs +++ b/src/Core/Utilities/DistributedCacheExtensions.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Microsoft.Extensions.Caching.Distributed; namespace Bit.Core.Utilities; diff --git a/src/Core/Utilities/EnumerationProtectionHelpers.cs b/src/Core/Utilities/EnumerationProtectionHelpers.cs new file mode 100644 index 0000000000..b27c36e03a --- /dev/null +++ b/src/Core/Utilities/EnumerationProtectionHelpers.cs @@ -0,0 +1,36 @@ +using System.Text; + +namespace Bit.Core.Utilities; + +public static class EnumerationProtectionHelpers +{ + /// + /// Use this method to get a consistent int result based on the inputString that is in the range. + /// The same inputString will always return the same index result based on range input. + /// + /// Key used to derive the HMAC hash. Use a different key for each usage for optimal security + /// The string to derive an index result + /// The range of possible index values + /// An int between 0 and range - 1 + public static int GetIndexForInputHash(byte[] hmacKey, string inputString, int range) + { + if (hmacKey == null || range <= 0 || hmacKey.Length == 0) + { + return 0; + } + else + { + // Compute the HMAC hash of the salt + var hmacMessage = Encoding.UTF8.GetBytes(inputString.Trim().ToLowerInvariant()); + using var hmac = new System.Security.Cryptography.HMACSHA256(hmacKey); + var hmacHash = hmac.ComputeHash(hmacMessage); + // Convert the hash to a number + var hashHex = BitConverter.ToString(hmacHash).Replace("-", string.Empty).ToLowerInvariant(); + var hashFirst8Bytes = hashHex[..16]; + var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber); + // Find the default KDF value for this hash number + var hashIndex = (int)(Math.Abs(hashNumber) % range); + return hashIndex; + } + } +} diff --git a/src/Core/Utilities/HandlebarsObjectJsonConverter.cs b/src/Core/Utilities/HandlebarsObjectJsonConverter.cs index 5651da4dc9..895c0ba263 100644 --- a/src/Core/Utilities/HandlebarsObjectJsonConverter.cs +++ b/src/Core/Utilities/HandlebarsObjectJsonConverter.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using System.Text.Json.Serialization; namespace Bit.Core.Utilities; diff --git a/src/Core/Utilities/JsonHelpers.cs b/src/Core/Utilities/JsonHelpers.cs index 3f06794b7c..af3964defd 100644 --- a/src/Core/Utilities/JsonHelpers.cs +++ b/src/Core/Utilities/JsonHelpers.cs @@ -1,4 +1,7 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using System.Net; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/src/Core/Utilities/KdfSettingsValidator.cs b/src/Core/Utilities/KdfSettingsValidator.cs index db7936acff..f89e8ddb66 100644 --- a/src/Core/Utilities/KdfSettingsValidator.cs +++ b/src/Core/Utilities/KdfSettingsValidator.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; namespace Bit.Core.Utilities; @@ -34,4 +35,9 @@ public static class KdfSettingsValidator break; } } + + public static IEnumerable Validate(KdfSettings settings) + { + return Validate(settings.KdfType, settings.Iterations, settings.Memory, settings.Parallelism); + } } diff --git a/src/Core/Utilities/LoggerFactoryExtensions.cs b/src/Core/Utilities/LoggerFactoryExtensions.cs index b2388bc499..54bd84df6f 100644 --- a/src/Core/Utilities/LoggerFactoryExtensions.cs +++ b/src/Core/Utilities/LoggerFactoryExtensions.cs @@ -30,7 +30,7 @@ public static class LoggerFactoryExtensions public static ILoggingBuilder AddSerilog( this ILoggingBuilder builder, WebHostBuilderContext context, - Func filter = null) + Func? filter = null) { var globalSettings = new GlobalSettings(); ConfigurationBinder.Bind(context.Configuration.GetSection("GlobalSettings"), globalSettings); @@ -54,19 +54,27 @@ public static class LoggerFactoryExtensions return filter(e, globalSettings); } + var logSentryWarning = false; + var logSyslogWarning = false; + + // Path format is the only required option for file logging, we will use that as + // the keystone for if they have configured the new location. + var newPathFormat = context.Configuration["Logging:PathFormat"]; + var config = new LoggerConfiguration() .MinimumLevel.Verbose() .Enrich.FromLogContext() .Filter.ByIncludingOnly(inclusionPredicate); - if (CoreHelpers.SettingHasValue(globalSettings?.Sentry.Dsn)) + if (CoreHelpers.SettingHasValue(globalSettings.Sentry.Dsn)) { config.WriteTo.Sentry(globalSettings.Sentry.Dsn) .Enrich.FromLogContext() .Enrich.WithProperty("Project", globalSettings.ProjectName); } - else if (CoreHelpers.SettingHasValue(globalSettings?.Syslog.Destination)) + else if (CoreHelpers.SettingHasValue(globalSettings.Syslog.Destination)) { + logSyslogWarning = true; // appending sitename to project name to allow easier identification in syslog. var appName = $"{globalSettings.SiteName}-{globalSettings.ProjectName}"; if (globalSettings.Syslog.Destination.Equals("local", StringComparison.OrdinalIgnoreCase)) @@ -104,10 +112,14 @@ public static class LoggerFactoryExtensions certProvider: new CertificateFileProvider(globalSettings.Syslog.CertificatePath, globalSettings.Syslog?.CertificatePassword ?? string.Empty)); } - } } } + else if (!string.IsNullOrEmpty(newPathFormat)) + { + // Use new location + builder.AddFile(context.Configuration.GetSection("Logging")); + } else if (CoreHelpers.SettingHasValue(globalSettings.LogDirectory)) { if (globalSettings.LogRollBySizeLimit.HasValue) @@ -135,6 +147,17 @@ public static class LoggerFactoryExtensions } var serilog = config.CreateLogger(); + + if (logSentryWarning) + { + serilog.Warning("Sentry for logging has been deprecated. Read more: https://btwrdn.com/log-deprecation"); + } + + if (logSyslogWarning) + { + serilog.Warning("Syslog for logging has been deprecated. Read more: https://btwrdn.com/log-deprecation"); + } + builder.AddSerilog(serilog); return builder; diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index 1cae361e29..1ddd926569 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -1,4 +1,7 @@ -using System.Collections.Immutable; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Collections.Immutable; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models.StaticStore.Plans; diff --git a/src/Core/Utilities/StrictEmailAddressAttribute.cs b/src/Core/Utilities/StrictEmailAddressAttribute.cs index fce732ec9e..64c95f8796 100644 --- a/src/Core/Utilities/StrictEmailAddressAttribute.cs +++ b/src/Core/Utilities/StrictEmailAddressAttribute.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Utilities; diff --git a/src/Core/Utilities/StrictEmailAddressListAttribute.cs b/src/Core/Utilities/StrictEmailAddressListAttribute.cs index 456980397a..ab13f9a819 100644 --- a/src/Core/Utilities/StrictEmailAddressListAttribute.cs +++ b/src/Core/Utilities/StrictEmailAddressListAttribute.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Utilities; diff --git a/src/Core/Utilities/SystemTextJsonCosmosSerializer.cs b/src/Core/Utilities/SystemTextJsonCosmosSerializer.cs index 8b2b8684e5..009dcc11e7 100644 --- a/src/Core/Utilities/SystemTextJsonCosmosSerializer.cs +++ b/src/Core/Utilities/SystemTextJsonCosmosSerializer.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Azure.Core.Serialization; using Microsoft.Azure.Cosmos; diff --git a/src/Core/Vault/Commands/ArchiveCiphersCommand.cs b/src/Core/Vault/Commands/ArchiveCiphersCommand.cs new file mode 100644 index 0000000000..6c8e0fcf75 --- /dev/null +++ b/src/Core/Vault/Commands/ArchiveCiphersCommand.cs @@ -0,0 +1,61 @@ +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Vault.Commands.Interfaces; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Vault.Commands; + +public class ArchiveCiphersCommand : IArchiveCiphersCommand +{ + private readonly ICipherRepository _cipherRepository; + private readonly IPushNotificationService _pushService; + + public ArchiveCiphersCommand( + ICipherRepository cipherRepository, + IPushNotificationService pushService + ) + { + _cipherRepository = cipherRepository; + _pushService = pushService; + } + + public async Task> ArchiveManyAsync(IEnumerable cipherIds, + Guid archivingUserId) + { + var cipherIdEnumerable = cipherIds as Guid[] ?? cipherIds.ToArray(); + if (cipherIds == null || cipherIdEnumerable.Length == 0) + throw new BadRequestException("No cipher ids provided."); + + var cipherIdsSet = new HashSet(cipherIdEnumerable); + + var ciphers = await _cipherRepository.GetManyByUserIdAsync(archivingUserId); + + if (ciphers == null || ciphers.Count == 0) + { + return []; + } + + var archivingCiphers = ciphers + .Where(c => cipherIdsSet.Contains(c.Id) && c is { Edit: true, OrganizationId: null, ArchivedDate: null }) + .ToList(); + + var revisionDate = await _cipherRepository.ArchiveAsync(archivingCiphers.Select(c => c.Id), archivingUserId); + + // Adding specifyKind because revisionDate is currently coming back as Unspecified from the database + revisionDate = DateTime.SpecifyKind(revisionDate, DateTimeKind.Utc); + + archivingCiphers.ForEach(c => + { + c.RevisionDate = revisionDate; + c.ArchivedDate = revisionDate; + }); + + // Will not log an event because the archive feature is limited to individual ciphers, and event logs only apply to organization ciphers. + // Add event logging here if this is expanded to organization ciphers in the future. + + await _pushService.PushSyncCiphersAsync(archivingUserId); + + return archivingCiphers; + } +} diff --git a/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs b/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs index e68a2ed726..fbd957afae 100644 --- a/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs +++ b/src/Core/Vault/Commands/CreateManyTaskNotificationsCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.NotificationCenter.Commands.Interfaces; using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Enums; @@ -89,7 +92,7 @@ public class CreateManyTaskNotificationsCommand : ICreateManyTaskNotificationsCo } // Notify the user that they have pending security tasks - await _pushNotificationService.PushPendingSecurityTasksAsync(userId); + await _pushNotificationService.PushRefreshSecurityTasksAsync(userId); } } } diff --git a/src/Core/Vault/Commands/Interfaces/IArchiveCiphersCommand.cs b/src/Core/Vault/Commands/Interfaces/IArchiveCiphersCommand.cs new file mode 100644 index 0000000000..63df62f160 --- /dev/null +++ b/src/Core/Vault/Commands/Interfaces/IArchiveCiphersCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Vault.Commands.Interfaces; + +public interface IArchiveCiphersCommand +{ + /// + /// Archives a cipher. This fills in the ArchivedDate property on a Cipher. + /// + /// Cipher ID to archive. + /// User ID to check against the Ciphers that are trying to be archived. + /// + public Task> ArchiveManyAsync(IEnumerable cipherIds, Guid archivingUserId); +} diff --git a/src/Core/Vault/Commands/Interfaces/IMarkNotificationsForTaskAsDeletedCommand.cs b/src/Core/Vault/Commands/Interfaces/IMarkNotificationsForTaskAsDeletedCommand.cs new file mode 100644 index 0000000000..90566b4d83 --- /dev/null +++ b/src/Core/Vault/Commands/Interfaces/IMarkNotificationsForTaskAsDeletedCommand.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Vault.Commands.Interfaces; + +public interface IMarkNotificationsForTaskAsDeletedCommand +{ + /// + /// Marks notifications associated with a given taskId as deleted. + /// + /// The unique identifier of the task to complete + /// A task representing the async operation + Task MarkAsDeletedAsync(Guid taskId); +} diff --git a/src/Core/Vault/Commands/Interfaces/IUnarchiveCiphersCommand.cs b/src/Core/Vault/Commands/Interfaces/IUnarchiveCiphersCommand.cs new file mode 100644 index 0000000000..4ed683c0a2 --- /dev/null +++ b/src/Core/Vault/Commands/Interfaces/IUnarchiveCiphersCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Vault.Commands.Interfaces; + +public interface IUnarchiveCiphersCommand +{ + /// + /// Unarchives a cipher. This nulls the ArchivedDate property on a Cipher. + /// + /// Cipher ID to unarchive. + /// User ID to check against the Ciphers that are trying to be unarchived. + /// + public Task> UnarchiveManyAsync(IEnumerable cipherIds, Guid unarchivingUserId); +} diff --git a/src/Core/Vault/Commands/MarkNotificationsForTaskAsDeletedCommand.cs b/src/Core/Vault/Commands/MarkNotificationsForTaskAsDeletedCommand.cs new file mode 100644 index 0000000000..65fe98a4d2 --- /dev/null +++ b/src/Core/Vault/Commands/MarkNotificationsForTaskAsDeletedCommand.cs @@ -0,0 +1,32 @@ +using Bit.Core.NotificationCenter.Repositories; +using Bit.Core.Platform.Push; +using Bit.Core.Vault.Commands.Interfaces; + +namespace Bit.Core.Vault.Commands; + +public class MarkNotificationsForTaskAsDeletedCommand : IMarkNotificationsForTaskAsDeletedCommand +{ + private readonly INotificationRepository _notificationRepository; + private readonly IPushNotificationService _pushNotificationService; + + public MarkNotificationsForTaskAsDeletedCommand( + INotificationRepository notificationRepository, + IPushNotificationService pushNotificationService) + { + _notificationRepository = notificationRepository; + _pushNotificationService = pushNotificationService; + + } + + public async Task MarkAsDeletedAsync(Guid taskId) + { + var userIds = await _notificationRepository.MarkNotificationsAsDeletedByTask(taskId); + + // For each user associated with the notifications, send a push notification so local tasks can be updated. + var uniqueUserIds = userIds.Distinct(); + foreach (var id in uniqueUserIds) + { + await _pushNotificationService.PushRefreshSecurityTasksAsync(id); + } + } +} diff --git a/src/Core/Vault/Commands/MarkTaskAsCompletedCommand.cs b/src/Core/Vault/Commands/MarkTaskAsCompletedCommand.cs index 77b8a8625c..8a12910bb8 100644 --- a/src/Core/Vault/Commands/MarkTaskAsCompletedCommand.cs +++ b/src/Core/Vault/Commands/MarkTaskAsCompletedCommand.cs @@ -14,15 +14,19 @@ public class MarkTaskAsCompletedCommand : IMarkTaskAsCompleteCommand private readonly ISecurityTaskRepository _securityTaskRepository; private readonly IAuthorizationService _authorizationService; private readonly ICurrentContext _currentContext; + private readonly IMarkNotificationsForTaskAsDeletedCommand _markNotificationsForTaskAsDeletedAsync; + public MarkTaskAsCompletedCommand( ISecurityTaskRepository securityTaskRepository, IAuthorizationService authorizationService, - ICurrentContext currentContext) + ICurrentContext currentContext, + IMarkNotificationsForTaskAsDeletedCommand markNotificationsForTaskAsDeletedAsync) { _securityTaskRepository = securityTaskRepository; _authorizationService = authorizationService; _currentContext = currentContext; + _markNotificationsForTaskAsDeletedAsync = markNotificationsForTaskAsDeletedAsync; } /// @@ -46,5 +50,8 @@ public class MarkTaskAsCompletedCommand : IMarkTaskAsCompleteCommand task.RevisionDate = DateTime.UtcNow; await _securityTaskRepository.ReplaceAsync(task); + + // Mark all notifications related to this task as deleted + await _markNotificationsForTaskAsDeletedAsync.MarkAsDeletedAsync(taskId); } } diff --git a/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs b/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs new file mode 100644 index 0000000000..83dcbab4e1 --- /dev/null +++ b/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs @@ -0,0 +1,60 @@ +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Vault.Commands.Interfaces; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Vault.Commands; + +public class UnarchiveCiphersCommand : IUnarchiveCiphersCommand +{ + private readonly ICipherRepository _cipherRepository; + private readonly IPushNotificationService _pushService; + + public UnarchiveCiphersCommand( + ICipherRepository cipherRepository, + IPushNotificationService pushService + ) + { + _cipherRepository = cipherRepository; + _pushService = pushService; + } + + public async Task> UnarchiveManyAsync(IEnumerable cipherIds, + Guid unarchivingUserId) + { + var cipherIdEnumerable = cipherIds as Guid[] ?? cipherIds.ToArray(); + if (cipherIds == null || cipherIdEnumerable.Length == 0) + throw new BadRequestException("No cipher ids provided."); + + var cipherIdsSet = new HashSet(cipherIdEnumerable); + + var ciphers = await _cipherRepository.GetManyByUserIdAsync(unarchivingUserId); + + if (ciphers == null || ciphers.Count == 0) + { + return []; + } + + var unarchivingCiphers = ciphers + .Where(c => cipherIdsSet.Contains(c.Id) && c is { Edit: true, ArchivedDate: not null }) + .ToList(); + + var revisionDate = + await _cipherRepository.UnarchiveAsync(unarchivingCiphers.Select(c => c.Id), unarchivingUserId); + // Adding specifyKind because revisionDate is currently coming back as Unspecified from the database + revisionDate = DateTime.SpecifyKind(revisionDate, DateTimeKind.Utc); + + unarchivingCiphers.ForEach(c => + { + c.RevisionDate = revisionDate; + c.ArchivedDate = null; + }); + // Will not log an event because the archive feature is limited to individual ciphers, and event logs only apply to organization ciphers. + // Add event logging here if this is expanded to organization ciphers in the future. + + await _pushService.PushSyncCiphersAsync(unarchivingUserId); + + return unarchivingCiphers; + } +} diff --git a/src/Core/Vault/Entities/Cipher.cs b/src/Core/Vault/Entities/Cipher.cs index 6a3ce94cf1..f6afc090bb 100644 --- a/src/Core/Vault/Entities/Cipher.cs +++ b/src/Core/Vault/Entities/Cipher.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Entities; using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; @@ -22,6 +25,7 @@ public class Cipher : ITableObject, ICloneable public DateTime? DeletedDate { get; set; } public Enums.CipherRepromptType? Reprompt { get; set; } public string Key { get; set; } + public DateTime? ArchivedDate { get; set; } public void SetNewId() { diff --git a/src/Core/Vault/Entities/SecurityTaskMetrics.cs b/src/Core/Vault/Entities/SecurityTaskMetrics.cs new file mode 100644 index 0000000000..c4172f6af9 --- /dev/null +++ b/src/Core/Vault/Entities/SecurityTaskMetrics.cs @@ -0,0 +1,13 @@ +namespace Bit.Core.Vault.Entities; + +public class SecurityTaskMetrics +{ + public SecurityTaskMetrics(int completedTasks, int totalTasks) + { + CompletedTasks = completedTasks; + TotalTasks = totalTasks; + } + + public int CompletedTasks { get; set; } + public int TotalTasks { get; set; } +} diff --git a/src/Core/Vault/Enums/CipherStateAction.cs b/src/Core/Vault/Enums/CipherStateAction.cs index adbc78c06c..d63315e63f 100644 --- a/src/Core/Vault/Enums/CipherStateAction.cs +++ b/src/Core/Vault/Enums/CipherStateAction.cs @@ -3,6 +3,8 @@ public enum CipherStateAction { Restore, + Unarchive, + Archive, SoftDelete, HardDelete, } diff --git a/src/Core/Vault/Models/Data/AttachmentResponseData.cs b/src/Core/Vault/Models/Data/AttachmentResponseData.cs index ecb1404912..c55d29053c 100644 --- a/src/Core/Vault/Models/Data/AttachmentResponseData.cs +++ b/src/Core/Vault/Models/Data/AttachmentResponseData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Vault.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Vault.Entities; namespace Bit.Core.Vault.Models.Data; diff --git a/src/Core/Vault/Models/Data/CipherAttachment.cs b/src/Core/Vault/Models/Data/CipherAttachment.cs index 6450efe632..1d4335f970 100644 --- a/src/Core/Vault/Models/Data/CipherAttachment.cs +++ b/src/Core/Vault/Models/Data/CipherAttachment.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Core.Vault.Models.Data; diff --git a/src/Core/Vault/Models/Data/CipherCardData.cs b/src/Core/Vault/Models/Data/CipherCardData.cs index 72a60176f2..a7d6e4c0db 100644 --- a/src/Core/Vault/Models/Data/CipherCardData.cs +++ b/src/Core/Vault/Models/Data/CipherCardData.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Vault.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Vault.Models.Data; public class CipherCardData : CipherData { diff --git a/src/Core/Vault/Models/Data/CipherData.cs b/src/Core/Vault/Models/Data/CipherData.cs index 459ee0d739..10e0315453 100644 --- a/src/Core/Vault/Models/Data/CipherData.cs +++ b/src/Core/Vault/Models/Data/CipherData.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Vault.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Vault.Models.Data; public abstract class CipherData { diff --git a/src/Core/Vault/Models/Data/CipherFieldData.cs b/src/Core/Vault/Models/Data/CipherFieldData.cs index b7969b11a2..303aa1da10 100644 --- a/src/Core/Vault/Models/Data/CipherFieldData.cs +++ b/src/Core/Vault/Models/Data/CipherFieldData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Vault.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Vault.Enums; namespace Bit.Core.Vault.Models.Data; diff --git a/src/Core/Vault/Models/Data/CipherIdentityData.cs b/src/Core/Vault/Models/Data/CipherIdentityData.cs index 9a8b2811ae..c4b251ba6f 100644 --- a/src/Core/Vault/Models/Data/CipherIdentityData.cs +++ b/src/Core/Vault/Models/Data/CipherIdentityData.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Vault.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Vault.Models.Data; public class CipherIdentityData : CipherData { diff --git a/src/Core/Vault/Models/Data/CipherLoginData.cs b/src/Core/Vault/Models/Data/CipherLoginData.cs index e2d1776abd..cdbe566c2f 100644 --- a/src/Core/Vault/Models/Data/CipherLoginData.cs +++ b/src/Core/Vault/Models/Data/CipherLoginData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; namespace Bit.Core.Vault.Models.Data; diff --git a/src/Core/Vault/Models/Data/CipherLoginFido2CredentialData.cs b/src/Core/Vault/Models/Data/CipherLoginFido2CredentialData.cs index eefb7ec6ad..738e43a7cd 100644 --- a/src/Core/Vault/Models/Data/CipherLoginFido2CredentialData.cs +++ b/src/Core/Vault/Models/Data/CipherLoginFido2CredentialData.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Vault.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Vault.Models.Data; public class CipherLoginFido2CredentialData { diff --git a/src/Core/Vault/Models/Data/CipherPasswordHistoryData.cs b/src/Core/Vault/Models/Data/CipherPasswordHistoryData.cs index 3cac41f416..ef48674731 100644 --- a/src/Core/Vault/Models/Data/CipherPasswordHistoryData.cs +++ b/src/Core/Vault/Models/Data/CipherPasswordHistoryData.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Vault.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Vault.Models.Data; public class CipherPasswordHistoryData { diff --git a/src/Core/Vault/Models/Data/CipherSSHKeyData.cs b/src/Core/Vault/Models/Data/CipherSSHKeyData.cs index 45c2cf6074..5138ce7d21 100644 --- a/src/Core/Vault/Models/Data/CipherSSHKeyData.cs +++ b/src/Core/Vault/Models/Data/CipherSSHKeyData.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Vault.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Vault.Models.Data; public class CipherSSHKeyData : CipherData { diff --git a/src/Core/Vault/Models/Data/UserCipherForTask.cs b/src/Core/Vault/Models/Data/UserCipherForTask.cs index 3ddaa141b1..27166e7242 100644 --- a/src/Core/Vault/Models/Data/UserCipherForTask.cs +++ b/src/Core/Vault/Models/Data/UserCipherForTask.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Vault.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Vault.Models.Data; /// /// Minimal data model that represents a User and the associated cipher for a security task. diff --git a/src/Core/Vault/Models/Data/UserSecurityTaskCipher.cs b/src/Core/Vault/Models/Data/UserSecurityTaskCipher.cs index 20e59ec4f7..a884163f42 100644 --- a/src/Core/Vault/Models/Data/UserSecurityTaskCipher.cs +++ b/src/Core/Vault/Models/Data/UserSecurityTaskCipher.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Vault.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Vault.Models.Data; /// /// Data model that represents a User and the associated cipher for a security task. diff --git a/src/Core/Vault/Models/Data/UserSecurityTasksCount.cs b/src/Core/Vault/Models/Data/UserSecurityTasksCount.cs index c8d2707db6..7385f77593 100644 --- a/src/Core/Vault/Models/Data/UserSecurityTasksCount.cs +++ b/src/Core/Vault/Models/Data/UserSecurityTasksCount.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Vault.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Vault.Models.Data; /// /// Data model that represents a User and the amount of actionable security tasks. diff --git a/src/Core/Vault/Queries/GetTaskMetricsForOrganizationQuery.cs b/src/Core/Vault/Queries/GetTaskMetricsForOrganizationQuery.cs new file mode 100644 index 0000000000..f51efe6274 --- /dev/null +++ b/src/Core/Vault/Queries/GetTaskMetricsForOrganizationQuery.cs @@ -0,0 +1,42 @@ +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Bit.Core.Vault.Authorization.SecurityTasks; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Repositories; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.Vault.Queries; + +public class GetTaskMetricsForOrganizationQuery : IGetTaskMetricsForOrganizationQuery +{ + private readonly ISecurityTaskRepository _securityTaskRepository; + private readonly IAuthorizationService _authorizationService; + private readonly ICurrentContext _currentContext; + + public GetTaskMetricsForOrganizationQuery( + ISecurityTaskRepository securityTaskRepository, + IAuthorizationService authorizationService, + ICurrentContext currentContext + ) + { + _securityTaskRepository = securityTaskRepository; + _authorizationService = authorizationService; + _currentContext = currentContext; + } + + public async Task GetTaskMetrics(Guid organizationId) + { + var organization = _currentContext.GetOrganization(organizationId); + var userId = _currentContext.UserId; + + if (organization == null || !userId.HasValue) + { + throw new NotFoundException(); + } + + await _authorizationService.AuthorizeOrThrowAsync(_currentContext.HttpContext.User, organization, SecurityTaskOperations.ListAllForOrganization); + + return await _securityTaskRepository.GetTaskMetricsAsync(organizationId); + } +} diff --git a/src/Core/Vault/Queries/IGetTaskMetricsForOrganizationQuery.cs b/src/Core/Vault/Queries/IGetTaskMetricsForOrganizationQuery.cs new file mode 100644 index 0000000000..49054e484d --- /dev/null +++ b/src/Core/Vault/Queries/IGetTaskMetricsForOrganizationQuery.cs @@ -0,0 +1,13 @@ +using Bit.Core.Vault.Entities; + +namespace Bit.Core.Vault.Queries; + +public interface IGetTaskMetricsForOrganizationQuery +{ + /// + /// Retrieves security task metrics for an organization. + /// + /// The Id of the organization + /// Metrics for all security tasks within an organization. + Task GetTaskMetrics(Guid organizationId); +} diff --git a/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs b/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs index 1756cad3c7..44a56eac48 100644 --- a/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs +++ b/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs @@ -37,4 +37,10 @@ public interface IOrganizationCiphersQuery /// public Task> GetOrganizationCiphersByCollectionIds( Guid organizationId, IEnumerable collectionIds); + + /// + /// Returns all organization ciphers except those in default user collections. + /// + public Task> + GetAllOrganizationCiphersExcludingDefaultUserCollections(Guid organizationId); } diff --git a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs index deed121216..62b055b417 100644 --- a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs +++ b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs @@ -24,7 +24,7 @@ public class OrganizationCiphersQuery : IOrganizationCiphersQuery var orgCiphers = ciphers.Where(c => c.OrganizationId == organizationId).ToList(); var orgCipherIds = orgCiphers.Select(c => c.Id); - var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(organizationId); + var collectionCiphers = await _collectionCipherRepository.GetManySharedByOrganizationIdAsync(organizationId); var collectionCiphersGroupDict = collectionCiphers .Where(c => orgCipherIds.Contains(c.CipherId)) .GroupBy(c => c.CipherId).ToDictionary(s => s.Key); @@ -61,4 +61,10 @@ public class OrganizationCiphersQuery : IOrganizationCiphersQuery var allOrganizationCiphers = await GetAllOrganizationCiphers(organizationId); return allOrganizationCiphers.Where(c => c.CollectionIds.Intersect(managedCollectionIds).Any()); } + + public async Task> + GetAllOrganizationCiphersExcludingDefaultUserCollections(Guid orgId) + { + return (await _cipherRepository.GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(orgId)).ToList(); + } } diff --git a/src/Core/Vault/Repositories/ICipherRepository.cs b/src/Core/Vault/Repositories/ICipherRepository.cs index 46742c6aa3..94518bae2a 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -25,6 +25,7 @@ public interface ICipherRepository : IRepository Task ReplaceAsync(Cipher obj, IEnumerable collectionIds); Task UpdatePartialAsync(Guid id, Guid userId, Guid? folderId, bool favorite); Task UpdateAttachmentAsync(CipherAttachment attachment); + Task ArchiveAsync(IEnumerable ids, Guid userId); Task DeleteAttachmentAsync(Guid cipherId, string attachmentId); Task DeleteAsync(IEnumerable ids, Guid userId); Task DeleteByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); @@ -40,6 +41,7 @@ public interface ICipherRepository : IRepository IEnumerable collectionCiphers, IEnumerable collectionUsers); Task SoftDeleteAsync(IEnumerable ids, Guid userId); Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); + Task UnarchiveAsync(IEnumerable ids, Guid userId); Task RestoreAsync(IEnumerable ids, Guid userId); Task RestoreByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); Task DeleteDeletedAsync(DateTime deletedDateBefore); @@ -55,7 +57,7 @@ public interface ICipherRepository : IRepository Guid userId); /// - /// Returns the users and the cipher ids for security tawsks that are applicable to them. + /// Returns the users and the cipher ids for security tasks that are applicable to them. /// /// Security tasks are actionable when a user has manage access to the associated cipher. /// @@ -68,4 +70,10 @@ public interface ICipherRepository : IRepository /// A list of ciphers with updated data UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, IEnumerable ciphers); + + /// + /// Returns all ciphers belonging to the organization excluding those with default collections + /// + Task> + GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(Guid organizationId); } diff --git a/src/Core/Vault/Repositories/ISecurityTaskRepository.cs b/src/Core/Vault/Repositories/ISecurityTaskRepository.cs index cc8303345d..4b88f1c0e8 100644 --- a/src/Core/Vault/Repositories/ISecurityTaskRepository.cs +++ b/src/Core/Vault/Repositories/ISecurityTaskRepository.cs @@ -28,4 +28,11 @@ public interface ISecurityTaskRepository : IRepository /// Collection of tasks to create /// Collection of created security tasks Task> CreateManyAsync(IEnumerable tasks); + + /// + /// Retrieves security task metrics for an organization. + /// + /// The id of the organization + /// A collection of security task metrics + Task GetTaskMetricsAsync(Guid organizationId); } diff --git a/src/Core/Vault/Services/ICipherService.cs b/src/Core/Vault/Services/ICipherService.cs index d3f8d20c90..ffd79e9381 100644 --- a/src/Core/Vault/Services/ICipherService.cs +++ b/src/Core/Vault/Services/ICipherService.cs @@ -1,4 +1,7 @@ -using Bit.Core.Vault.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; namespace Bit.Core.Vault.Services; @@ -34,4 +37,5 @@ public interface ICipherService Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId); Task GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId); Task ValidateCipherAttachmentFile(Cipher cipher, CipherAttachment.MetaData attachmentData); + Task ValidateBulkCollectionAssignmentAsync(IEnumerable collectionIds, IEnumerable cipherIds, Guid userId); } diff --git a/src/Core/Vault/Services/Implementations/AzureAttachmentStorageService.cs b/src/Core/Vault/Services/Implementations/AzureAttachmentStorageService.cs index 89b152a645..d03a7e5fcf 100644 --- a/src/Core/Vault/Services/Implementations/AzureAttachmentStorageService.cs +++ b/src/Core/Vault/Services/Implementations/AzureAttachmentStorageService.cs @@ -1,4 +1,7 @@ -using Azure.Storage.Blobs; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Azure.Storage.Sas; using Bit.Core.Enums; diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 42221adf4b..f132588e37 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -167,6 +170,7 @@ public class CipherService : ICipherService { ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); cipher.RevisionDate = DateTime.UtcNow; + await ValidateChangeInCollectionsAsync(cipher, collectionIds, savingUserId); await ValidateViewPasswordUserAsync(cipher); await _cipherRepository.ReplaceAsync(cipher); await _eventService.LogCipherEventAsync(cipher, Bit.Core.Enums.EventType.Cipher_Updated); @@ -478,7 +482,7 @@ public class CipherService : ICipherService throw new NotFoundException(); } await _cipherRepository.DeleteByOrganizationIdAsync(organizationId); - await _eventService.LogOrganizationEventAsync(org, Bit.Core.Enums.EventType.Organization_PurgedVault); + await _eventService.LogOrganizationEventAsync(org, EventType.Organization_PurgedVault); } public async Task MoveManyAsync(IEnumerable cipherIds, Guid? destinationFolderId, Guid movingUserId) @@ -536,6 +540,7 @@ public class CipherService : ICipherService try { await ValidateCipherCanBeShared(cipher, sharingUserId, organizationId, lastKnownRevisionDate); + await ValidateChangeInCollectionsAsync(cipher, collectionIds, sharingUserId); // Sproc will not save this UserId on the cipher. It is used limit scope of the collectionIds. cipher.UserId = sharingUserId; @@ -667,6 +672,7 @@ public class CipherService : ICipherService { throw new BadRequestException("Cipher must belong to an organization."); } + await ValidateChangeInCollectionsAsync(cipher, collectionIds, savingUserId); cipher.RevisionDate = DateTime.UtcNow; @@ -686,7 +692,7 @@ public class CipherService : ICipherService await _collectionCipherRepository.UpdateCollectionsAsync(cipher.Id, savingUserId, collectionIds); } - await _eventService.LogCipherEventAsync(cipher, Bit.Core.Enums.EventType.Cipher_UpdatedCollections); + await _eventService.LogCipherEventAsync(cipher, EventType.Cipher_UpdatedCollections); // push await _pushService.PushSyncCipherUpdateAsync(cipher, collectionIds); @@ -707,6 +713,13 @@ public class CipherService : ICipherService cipherDetails.DeletedDate = cipherDetails.RevisionDate = DateTime.UtcNow; + if (cipherDetails.ArchivedDate.HasValue) + { + // If the cipher was archived, clear the archived date when soft deleting + // If a user were to restore an archived cipher, it should go back to the vault not the archive vault + cipherDetails.ArchivedDate = null; + } + await _cipherRepository.UpsertAsync(cipherDetails); await _eventService.LogCipherEventAsync(cipherDetails, EventType.Cipher_SoftDeleted); @@ -775,8 +788,8 @@ public class CipherService : ICipherService } var cipherIdsSet = new HashSet(cipherIds); - var restoringCiphers = new List(); - DateTime? revisionDate; + List restoringCiphers; + DateTime? revisionDate; // TODO: Make this not nullable if (orgAdmin && organizationId.HasValue) { @@ -809,6 +822,15 @@ public class CipherService : ICipherService return restoringCiphers; } + public async Task ValidateBulkCollectionAssignmentAsync(IEnumerable collectionIds, IEnumerable cipherIds, Guid userId) + { + foreach (var cipherId in cipherIds) + { + var cipher = await _cipherRepository.GetByIdAsync(cipherId); + await ValidateChangeInCollectionsAsync(cipher, collectionIds, userId); + } + } + private async Task UserCanEditAsync(Cipher cipher, Guid userId) { if (!cipher.OrganizationId.HasValue && cipher.UserId.HasValue && cipher.UserId.Value == userId) @@ -922,7 +944,7 @@ public class CipherService : ICipherService // Users that get access to file storage/premium from their organization get the default // 1 GB max storage. storageBytesRemaining = user.StorageBytesRemaining( - _globalSettings.SelfHosted ? (short)10240 : (short)1); + _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1); } } else if (cipher.OrganizationId.HasValue) @@ -960,6 +982,11 @@ public class CipherService : ICipherService throw new BadRequestException("One or more ciphers do not belong to you."); } + if (cipher.ArchivedDate.HasValue) + { + throw new BadRequestException("Cipher cannot be shared with organization because it is archived."); + } + var attachments = cipher.GetAttachments(); var hasAttachments = attachments?.Any() ?? false; var org = await _organizationRepository.GetByIdAsync(organizationId); @@ -1022,6 +1049,44 @@ public class CipherService : ICipherService } } + // Validates that a cipher is not being added to a default collection when it is only currently only in shared collections + private async Task ValidateChangeInCollectionsAsync(Cipher updatedCipher, IEnumerable newCollectionIds, Guid userId) + { + + if (updatedCipher.Id == Guid.Empty || !updatedCipher.OrganizationId.HasValue) + { + return; + } + + var currentCollectionsForCipher = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, updatedCipher.Id); + + if (!currentCollectionsForCipher.Any()) + { + // When a cipher is not currently in any collections it can be assigned to any type of collection + return; + } + + var currentCollections = await _collectionRepository.GetManyByManyIdsAsync(currentCollectionsForCipher.Select(c => c.CollectionId)); + + var currentCollectionsContainDefault = currentCollections.Any(c => c.Type == CollectionType.DefaultUserCollection); + + // When the current cipher already contains the default collection, no check is needed for if they added or removed + // a default collection, because it is already there. + if (currentCollectionsContainDefault) + { + return; + } + + var newCollections = await _collectionRepository.GetManyByManyIdsAsync(newCollectionIds); + var newCollectionsContainDefault = newCollections.Any(c => c.Type == CollectionType.DefaultUserCollection); + + if (newCollectionsContainDefault) + { + // User is trying to add the default collection when the cipher is only in shared collections + throw new BadRequestException("The cipher(s) cannot be assigned to a default collection when only assigned to non-default collections."); + } + } + private string SerializeCipherData(CipherData data) { return data switch diff --git a/src/Core/Vault/Services/NoopImplementations/NoopAttachmentStorageService.cs b/src/Core/Vault/Services/NoopImplementations/NoopAttachmentStorageService.cs index 6e6379d5b3..8014849d93 100644 --- a/src/Core/Vault/Services/NoopImplementations/NoopAttachmentStorageService.cs +++ b/src/Core/Vault/Services/NoopImplementations/NoopAttachmentStorageService.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; diff --git a/src/Core/Vault/VaultServiceCollectionExtensions.cs b/src/Core/Vault/VaultServiceCollectionExtensions.cs index 1f361cb613..93e86c0208 100644 --- a/src/Core/Vault/VaultServiceCollectionExtensions.cs +++ b/src/Core/Vault/VaultServiceCollectionExtensions.cs @@ -24,5 +24,9 @@ public static class VaultServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Events/Controllers/CollectController.cs b/src/Events/Controllers/CollectController.cs index 5eb48a2688..d7fbbbc595 100644 --- a/src/Events/Controllers/CollectController.cs +++ b/src/Events/Controllers/CollectController.cs @@ -1,4 +1,7 @@ -using Bit.Core.Context; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Events/Dockerfile b/src/Events/Dockerfile index 3a6342ef7a..913e94da45 100644 --- a/src/Events/Dockerfile +++ b/src/Events/Dockerfile @@ -1,7 +1,7 @@ ############################################### # Build stage # ############################################### -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build # Docker buildx supplies the value for this arg ARG TARGETPLATFORM @@ -9,11 +9,11 @@ ARG TARGETPLATFORM # Determine proper runtime value for .NET # We put the value in a file to be read by later layers. RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ - RID=linux-x64 ; \ + RID=linux-musl-x64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ - RID=linux-arm64 ; \ + RID=linux-musl-arm64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ - RID=linux-arm ; \ + RID=linux-musl-arm ; \ fi \ && echo "RID=$RID" > /tmp/rid.txt @@ -37,21 +37,21 @@ RUN . /tmp/rid.txt && dotnet publish \ ############################################### # App stage # ############################################### -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_URLS=http://+:5000 ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - krb5-user \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache curl \ + icu-libs \ + krb5 \ + shadow \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu # Copy app from the build stage WORKDIR /app diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index 5fc12854b6..cfe177aa2c 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -1,11 +1,13 @@ using System.Globalization; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Context; -using Bit.Core.IdentityServer; using Bit.Core.Services; +using Bit.Core.Services.Implementations; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; -using IdentityModel; +using Duende.IdentityModel; namespace Bit.Events; @@ -52,13 +54,18 @@ public class Startup // Services var usingServiceBusAppCache = CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) && CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName); + services.AddScoped(); + services.AddSingleton(); + if (usingServiceBusAppCache) { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } else { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } services.AddEventWriteServices(globalSettings); diff --git a/src/Events/entrypoint.sh b/src/Events/entrypoint.sh index 92b19195ea..427bd06e40 100644 --- a/src/Events/entrypoint.sh +++ b/src/Events/entrypoint.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # Setup @@ -37,7 +37,7 @@ then mkdir -p /etc/bitwarden/ca-certificates chown -R $USERNAME:$GROUPNAME /etc/bitwarden - if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos fi @@ -46,7 +46,7 @@ else gosu_cmd="" fi -if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then +if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi diff --git a/src/EventsProcessor/AzureQueueHostedService.cs b/src/EventsProcessor/AzureQueueHostedService.cs index b1b309b50f..c6f5afbfdd 100644 --- a/src/EventsProcessor/AzureQueueHostedService.cs +++ b/src/EventsProcessor/AzureQueueHostedService.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Azure.Storage.Queues; using Bit.Core; using Bit.Core.Models.Data; @@ -83,11 +86,24 @@ public class AzureQueueHostedService : IHostedService, IDisposable await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); } } + catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug("Task.Delay cancelled during Alpine container shutdown"); + break; + } catch (Exception ex) { _logger.LogError(ex, "Error occurred processing message block."); - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + try + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug("Task.Delay cancelled during Alpine container shutdown"); + break; + } } } diff --git a/src/EventsProcessor/Dockerfile b/src/EventsProcessor/Dockerfile index 928af7fb86..433552d321 100644 --- a/src/EventsProcessor/Dockerfile +++ b/src/EventsProcessor/Dockerfile @@ -1,7 +1,7 @@ ############################################### # Build stage # ############################################### -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build # Docker buildx supplies the value for this arg ARG TARGETPLATFORM @@ -9,11 +9,11 @@ ARG TARGETPLATFORM # Determine proper runtime value for .NET # We put the value in a file to be read by later layers. RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ - RID=linux-x64 ; \ + RID=linux-musl-x64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ - RID=linux-arm64 ; \ + RID=linux-musl-arm64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ - RID=linux-arm ; \ + RID=linux-musl-arm ; \ fi \ && echo "RID=$RID" > /tmp/rid.txt @@ -37,20 +37,20 @@ RUN . /tmp/rid.txt && dotnet publish \ ############################################### # App stage # ############################################### -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_URLS=http://+:5000 ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache curl \ + icu-libs \ + shadow \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu # Copy app from the build stage WORKDIR /app diff --git a/src/EventsProcessor/entrypoint.sh b/src/EventsProcessor/entrypoint.sh index e0d2dc0230..f5757bc180 100644 --- a/src/EventsProcessor/entrypoint.sh +++ b/src/EventsProcessor/entrypoint.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # Setup diff --git a/src/Icons/Controllers/ChangePasswordUriController.cs b/src/Icons/Controllers/ChangePasswordUriController.cs new file mode 100644 index 0000000000..935cda77df --- /dev/null +++ b/src/Icons/Controllers/ChangePasswordUriController.cs @@ -0,0 +1,89 @@ +using Bit.Icons.Models; +using Bit.Icons.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace Bit.Icons.Controllers; + +[Route("~/change-password-uri")] +public class ChangePasswordUriController : Controller +{ + private readonly IMemoryCache _memoryCache; + private readonly IDomainMappingService _domainMappingService; + private readonly IChangePasswordUriService _changePasswordService; + private readonly ChangePasswordUriSettings _changePasswordSettings; + private readonly ILogger _logger; + + public ChangePasswordUriController( + IMemoryCache memoryCache, + IDomainMappingService domainMappingService, + IChangePasswordUriService changePasswordService, + ChangePasswordUriSettings changePasswordUriSettings, + ILogger logger) + { + _memoryCache = memoryCache; + _domainMappingService = domainMappingService; + _changePasswordService = changePasswordService; + _changePasswordSettings = changePasswordUriSettings; + _logger = logger; + } + + [HttpGet("config")] + public IActionResult GetConfig() + { + return new JsonResult(new + { + _changePasswordSettings.CacheEnabled, + _changePasswordSettings.CacheHours, + _changePasswordSettings.CacheSizeLimit + }); + } + + [HttpGet] + public async Task Get([FromQuery] string uri) + { + if (string.IsNullOrWhiteSpace(uri)) + { + return new BadRequestResult(); + } + + var uriHasProtocol = uri.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + uri.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + + var url = uriHasProtocol ? uri : $"https://{uri}"; + if (!Uri.TryCreate(url, UriKind.Absolute, out var validUri)) + { + return new BadRequestResult(); + } + + var domain = validUri.Host; + + var mappedDomain = _domainMappingService.MapDomain(domain); + if (!_changePasswordSettings.CacheEnabled || !_memoryCache.TryGetValue(mappedDomain, out string? changePasswordUri)) + { + var result = await _changePasswordService.GetChangePasswordUri(domain); + if (result == null) + { + _logger.LogWarning("Null result returned for {0}.", domain); + changePasswordUri = null; + } + else + { + changePasswordUri = result; + } + + if (_changePasswordSettings.CacheEnabled) + { + _logger.LogInformation("Cache uri for {0}.", domain); + _memoryCache.Set(mappedDomain, changePasswordUri, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = new TimeSpan(_changePasswordSettings.CacheHours, 0, 0), + Size = changePasswordUri?.Length ?? 0, + Priority = changePasswordUri == null ? CacheItemPriority.High : CacheItemPriority.Normal + }); + } + } + + return Ok(new ChangePasswordUriResponse(changePasswordUri)); + } +} diff --git a/src/Icons/Controllers/IconsController.cs b/src/Icons/Controllers/IconsController.cs index 871219b366..0d32a8254b 100644 --- a/src/Icons/Controllers/IconsController.cs +++ b/src/Icons/Controllers/IconsController.cs @@ -1,4 +1,7 @@ -using Bit.Icons.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Icons.Models; using Bit.Icons.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; diff --git a/src/Icons/Dockerfile b/src/Icons/Dockerfile index 16c88e22fa..5cd2b405d4 100644 --- a/src/Icons/Dockerfile +++ b/src/Icons/Dockerfile @@ -1,18 +1,18 @@ ############################################### # Build stage # ############################################### -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build # Docker buildx supplies the value for this arg ARG TARGETPLATFORM # Determine proper runtime value for .NET RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ - RID=linux-x64 ; \ + RID=linux-musl-x64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ - RID=linux-arm64 ; \ + RID=linux-musl-arm64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ - RID=linux-arm ; \ + RID=linux-musl-arm ; \ fi \ && echo "RID=$RID" > /tmp/rid.txt @@ -36,20 +36,21 @@ RUN . /tmp/rid.txt && dotnet publish \ ############################################### # App stage # ############################################### -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_URLS=http://+:5000 ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache curl \ + krb5 \ + icu-libs \ + shadow \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu # Copy app from the build stage WORKDIR /app diff --git a/src/Icons/Models/ChangePasswordUriResponse.cs b/src/Icons/Models/ChangePasswordUriResponse.cs new file mode 100644 index 0000000000..def6806bd3 --- /dev/null +++ b/src/Icons/Models/ChangePasswordUriResponse.cs @@ -0,0 +1,11 @@ +namespace Bit.Icons.Models; + +public class ChangePasswordUriResponse +{ + public string? uri { get; set; } + + public ChangePasswordUriResponse(string? uri) + { + this.uri = uri; + } +} diff --git a/src/Icons/Models/ChangePasswordUriSettings.cs b/src/Icons/Models/ChangePasswordUriSettings.cs new file mode 100644 index 0000000000..bcb804f4e0 --- /dev/null +++ b/src/Icons/Models/ChangePasswordUriSettings.cs @@ -0,0 +1,8 @@ +namespace Bit.Icons.Models; + +public class ChangePasswordUriSettings +{ + public virtual bool CacheEnabled { get; set; } + public virtual int CacheHours { get; set; } + public virtual long? CacheSizeLimit { get; set; } +} diff --git a/src/Icons/Models/DomainName.cs b/src/Icons/Models/DomainName.cs index b040110504..8b90dff42d 100644 --- a/src/Icons/Models/DomainName.cs +++ b/src/Icons/Models/DomainName.cs @@ -1,4 +1,7 @@ -using System.Diagnostics; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Diagnostics; using System.Reflection; using System.Text.RegularExpressions; diff --git a/src/Icons/Models/Icon.cs b/src/Icons/Models/Icon.cs index 8bd23541fa..396a105716 100644 --- a/src/Icons/Models/Icon.cs +++ b/src/Icons/Models/Icon.cs @@ -1,4 +1,7 @@ -namespace Bit.Icons.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Icons.Models; public class Icon { diff --git a/src/Icons/Services/ChangePasswordUriService.cs b/src/Icons/Services/ChangePasswordUriService.cs new file mode 100644 index 0000000000..6f2b73efff --- /dev/null +++ b/src/Icons/Services/ChangePasswordUriService.cs @@ -0,0 +1,89 @@ +namespace Bit.Icons.Services; + +public class ChangePasswordUriService : IChangePasswordUriService +{ + private readonly HttpClient _httpClient; + + public ChangePasswordUriService(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient("ChangePasswordUri"); + } + + /// + /// Fetches the well-known change password URL for the given domain. + /// + /// + /// + public async Task GetChangePasswordUri(string domain) + { + if (string.IsNullOrWhiteSpace(domain)) + { + return null; + } + + var hasReliableStatusCode = await HasReliableHttpStatusCode(domain); + var wellKnownChangePasswordUrl = await GetWellKnownChangePasswordUrl(domain); + + + if (hasReliableStatusCode && wellKnownChangePasswordUrl != null) + { + return wellKnownChangePasswordUrl; + } + + // Reliable well-known URL criteria not met, return null + return null; + } + + /// + /// Checks if the server returns a non-200 status code for a resource that should not exist. + // See https://w3c.github.io/webappsec-change-password-url/response-code-reliability.html#semantics + /// + /// The domain of the URL to check + /// True when the domain responds with a non-ok response + private async Task HasReliableHttpStatusCode(string urlDomain) + { + try + { + var url = new UriBuilder(urlDomain) + { + Path = "/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200" + }; + + var request = new HttpRequestMessage(HttpMethod.Get, url.ToString()); + + var response = await _httpClient.SendAsync(request); + return !response.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + /// + /// Builds a well-known change password URL for the given origin. Attempts to fetch the URL to ensure a valid response + /// is returned. Returns null if the request throws or the response is not 200 OK. + /// See https://w3c.github.io/webappsec-change-password-url/ + /// + /// The domain of the URL to check + /// The well-known change password URL if valid, otherwise null + private async Task GetWellKnownChangePasswordUrl(string urlDomain) + { + try + { + var url = new UriBuilder(urlDomain) + { + Path = "/.well-known/change-password" + }; + + var request = new HttpRequestMessage(HttpMethod.Get, url.ToString()); + + var response = await _httpClient.SendAsync(request); + return response.IsSuccessStatusCode ? url.ToString() : null; + } + catch + { + return null; + } + } +} diff --git a/src/Icons/Services/IChangePasswordUriService.cs b/src/Icons/Services/IChangePasswordUriService.cs new file mode 100644 index 0000000000..f010255db5 --- /dev/null +++ b/src/Icons/Services/IChangePasswordUriService.cs @@ -0,0 +1,6 @@ +namespace Bit.Icons.Services; + +public interface IChangePasswordUriService +{ + Task GetChangePasswordUri(string domain); +} diff --git a/src/Icons/Startup.cs b/src/Icons/Startup.cs index 4695c320e9..2602dd6264 100644 --- a/src/Icons/Startup.cs +++ b/src/Icons/Startup.cs @@ -2,6 +2,7 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Icons.Extensions; +using Bit.Icons.Models; using Bit.SharedWeb.Utilities; using Microsoft.Net.Http.Headers; @@ -27,8 +28,11 @@ public class Startup // Settings var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment); var iconsSettings = new IconsSettings(); + var changePasswordUriSettings = new ChangePasswordUriSettings(); ConfigurationBinder.Bind(Configuration.GetSection("IconsSettings"), iconsSettings); + ConfigurationBinder.Bind(Configuration.GetSection("ChangePasswordUriSettings"), changePasswordUriSettings); services.AddSingleton(s => iconsSettings); + services.AddSingleton(s => changePasswordUriSettings); // Http client services.ConfigureHttpClients(); @@ -41,6 +45,10 @@ public class Startup { options.SizeLimit = iconsSettings.CacheSizeLimit; }); + services.AddMemoryCache(options => + { + options.SizeLimit = changePasswordUriSettings.CacheSizeLimit; + }); // Services services.AddServices(); @@ -84,6 +92,9 @@ public class Startup await next(); }); + app.UseCors(policy => policy.SetIsOriginAllowed(o => CoreHelpers.IsCorsOriginAllowed(o, globalSettings)) + .AllowAnyMethod().AllowAnyHeader().AllowCredentials()); + app.UseRouting(); app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute()); } diff --git a/src/Icons/Util/ServiceCollectionExtension.cs b/src/Icons/Util/ServiceCollectionExtension.cs index 5492cda0cf..3bd3537198 100644 --- a/src/Icons/Util/ServiceCollectionExtension.cs +++ b/src/Icons/Util/ServiceCollectionExtension.cs @@ -28,6 +28,24 @@ public static class ServiceCollectionExtension AllowAutoRedirect = false, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, }); + + // The CreatePasswordUri handler wants similar headers as Icons to portray coming from a browser but + // needs to follow redirects to get the final URL. + services.AddHttpClient("ChangePasswordUri", client => + { + client.Timeout = TimeSpan.FromSeconds(20); + client.MaxResponseContentBufferSize = 5000000; // 5 MB + // Let's add some headers to look like we're coming from a web browser request. Some websites + // will block our request without these. + client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"); + client.DefaultRequestHeaders.Add("Accept-Language", "en-US,en;q=0.8"); + client.DefaultRequestHeaders.Add("Cache-Control", "no-cache"); + client.DefaultRequestHeaders.Add("Pragma", "no-cache"); + }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + }); } public static void AddHtmlParsing(this IServiceCollection services) @@ -40,5 +58,6 @@ public static class ServiceCollectionExtension services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } } diff --git a/src/Icons/appsettings.Development.json b/src/Icons/appsettings.Development.json index fa8ce71a97..b7d7186ffa 100644 --- a/src/Icons/appsettings.Development.json +++ b/src/Icons/appsettings.Development.json @@ -1,4 +1,21 @@ { + "globalSettings": { + "baseServiceUri": { + "vault": "https://localhost:8080", + "api": "http://localhost:4000", + "identity": "http://localhost:33656", + "admin": "http://localhost:62911", + "notifications": "http://localhost:61840", + "sso": "http://localhost:51822", + "internalNotifications": "http://localhost:61840", + "internalAdmin": "http://localhost:62911", + "internalIdentity": "http://localhost:33656", + "internalApi": "http://localhost:4000", + "internalVault": "https://localhost:8080", + "internalSso": "http://localhost:51822", + "internalScim": "http://localhost:44559" + } + }, "Logging": { "IncludeScopes": false, "LogLevel": { diff --git a/src/Icons/appsettings.Production.json b/src/Icons/appsettings.Production.json index 437045a7fb..828e8c61cc 100644 --- a/src/Icons/appsettings.Production.json +++ b/src/Icons/appsettings.Production.json @@ -1,4 +1,21 @@ { + "globalSettings": { + "baseServiceUri": { + "vault": "https://vault.bitwarden.com", + "api": "https://api.bitwarden.com", + "identity": "https://identity.bitwarden.com", + "admin": "https://admin.bitwarden.com", + "notifications": "https://notifications.bitwarden.com", + "sso": "https://sso.bitwarden.com", + "internalNotifications": "https://notifications.bitwarden.com", + "internalAdmin": "https://admin.bitwarden.com", + "internalIdentity": "https://identity.bitwarden.com", + "internalApi": "https://api.bitwarden.com", + "internalVault": "https://vault.bitwarden.com", + "internalSso": "https://sso.bitwarden.com", + "internalScim": "https://scim.bitwarden.com" + } + }, "Logging": { "IncludeScopes": false, "LogLevel": { diff --git a/src/Icons/appsettings.QA.json b/src/Icons/appsettings.QA.json index aec6c424af..ad323c8af6 100644 --- a/src/Icons/appsettings.QA.json +++ b/src/Icons/appsettings.QA.json @@ -1,4 +1,21 @@ { + "globalSettings": { + "baseServiceUri": { + "vault": "https://vault.qa.bitwarden.pw", + "api": "https://api.qa.bitwarden.pw", + "identity": "https://identity.qa.bitwarden.pw", + "admin": "https://admin.qa.bitwarden.pw", + "notifications": "https://notifications.qa.bitwarden.pw", + "sso": "https://sso.qa.bitwarden.pw", + "internalNotifications": "https://notifications.qa.bitwarden.pw", + "internalAdmin": "https://admin.qa.bitwarden.pw", + "internalIdentity": "https://identity.qa.bitwarden.pw", + "internalApi": "https://api.qa.bitwarden.pw", + "internalVault": "https://vault.qa.bitwarden.pw", + "internalSso": "https://sso.qa.bitwarden.pw", + "internalScim": "https://scim.qa.bitwarden.pw" + } + }, "Logging": { "IncludeScopes": false, "LogLevel": { diff --git a/src/Icons/appsettings.SelfHosted.json b/src/Icons/appsettings.SelfHosted.json new file mode 100644 index 0000000000..37faf24b59 --- /dev/null +++ b/src/Icons/appsettings.SelfHosted.json @@ -0,0 +1,19 @@ +{ + "globalSettings": { + "baseServiceUri": { + "vault": null, + "api": null, + "identity": null, + "admin": null, + "notifications": null, + "sso": null, + "internalNotifications": null, + "internalAdmin": null, + "internalIdentity": null, + "internalApi": null, + "internalVault": null, + "internalSso": null, + "internalScim": null + } + } +} diff --git a/src/Icons/appsettings.json b/src/Icons/appsettings.json index 6b4e2992e0..5e1113b150 100644 --- a/src/Icons/appsettings.json +++ b/src/Icons/appsettings.json @@ -6,5 +6,10 @@ "cacheEnabled": true, "cacheHours": 24, "cacheSizeLimit": null + }, + "changePasswordUriSettings": { + "cacheEnabled": true, + "cacheHours": 24, + "cacheSizeLimit": null } } diff --git a/src/Icons/entrypoint.sh b/src/Icons/entrypoint.sh index c65d3b308d..02408d1a68 100644 --- a/src/Icons/entrypoint.sh +++ b/src/Icons/entrypoint.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # Setup @@ -37,7 +37,7 @@ then mkdir -p /etc/bitwarden/ca-certificates chown -R $USERNAME:$GROUPNAME /etc/bitwarden - if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos fi @@ -46,7 +46,7 @@ else gosu_cmd="" fi -if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then +if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi diff --git a/src/Identity/Billing/Controller/AccountsController.cs b/src/Identity/Billing/Controller/AccountsController.cs index f476e4e094..60daebde93 100644 --- a/src/Identity/Billing/Controller/AccountsController.cs +++ b/src/Identity/Billing/Controller/AccountsController.cs @@ -1,7 +1,5 @@ -using Bit.Core; -using Bit.Core.Billing.Models.Api.Requests.Accounts; +using Bit.Core.Billing.Models.Api.Requests.Accounts; using Bit.Core.Billing.TrialInitiation.Registration; -using Bit.Core.Services; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.Mvc; @@ -11,16 +9,13 @@ namespace Bit.Identity.Billing.Controller; [Route("accounts")] [ExceptionHandlerFilter] public class AccountsController( - ISendTrialInitiationEmailForRegistrationCommand sendTrialInitiationEmailForRegistrationCommand, - IFeatureService featureService) : Microsoft.AspNetCore.Mvc.Controller + ISendTrialInitiationEmailForRegistrationCommand sendTrialInitiationEmailForRegistrationCommand) : Microsoft.AspNetCore.Mvc.Controller { [HttpPost("trial/send-verification-email")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostTrialInitiationSendVerificationEmailAsync([FromBody] TrialSendVerificationEmailRequestModel model) { - var allowTrialLength0 = featureService.IsEnabled(FeatureFlagKeys.PM20322_AllowTrialLength0); - - var trialLength = allowTrialLength0 ? model.TrialLength ?? 7 : 7; + var trialLength = model.TrialLength ?? 7; var token = await sendTrialInitiationEmailForRegistrationCommand.Handle( model.Email, diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 4965046bfc..cc146800af 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -1,4 +1,7 @@ -using System.Diagnostics; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Diagnostics; using System.Text; using Bit.Core; using Bit.Core.Auth.Enums; diff --git a/src/Identity/Controllers/InfoController.cs b/src/Identity/Controllers/InfoController.cs index 05cf3f2363..79dfd99c44 100644 --- a/src/Identity/Controllers/InfoController.cs +++ b/src/Identity/Controllers/InfoController.cs @@ -6,12 +6,18 @@ namespace Bit.Identity.Controllers; public class InfoController : Controller { [HttpGet("~/alive")] - [HttpGet("~/now")] public DateTime GetAlive() { return DateTime.UtcNow; } + [HttpGet("~/now")] + [Obsolete("This endpoint is deprecated. Use GET /alive instead.")] + public DateTime GetNow() + { + return GetAlive(); + } + [HttpGet("~/version")] public JsonResult GetVersion() { diff --git a/src/Identity/Controllers/SsoController.cs b/src/Identity/Controllers/SsoController.cs index f3dc301a61..6f843d6ee7 100644 --- a/src/Identity/Controllers/SsoController.cs +++ b/src/Identity/Controllers/SsoController.cs @@ -1,13 +1,16 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; using Bit.Core.Models.Api; using Bit.Core.Repositories; using Bit.Identity.Models; +using Duende.IdentityModel; using Duende.IdentityServer; using Duende.IdentityServer.Services; -using IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Identity/Dockerfile b/src/Identity/Dockerfile index 9b9ae41334..e79439f275 100644 --- a/src/Identity/Dockerfile +++ b/src/Identity/Dockerfile @@ -1,7 +1,7 @@ ############################################### # Build stage # ############################################### -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build # Docker buildx supplies the value for this arg ARG TARGETPLATFORM @@ -9,11 +9,11 @@ ARG TARGETPLATFORM # Determine proper runtime value for .NET # We put the value in a file to be read by later layers. RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ - RID=linux-x64 ; \ + RID=linux-musl-x64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ - RID=linux-arm64 ; \ + RID=linux-musl-arm64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ - RID=linux-arm ; \ + RID=linux-musl-arm ; \ fi \ && echo "RID=$RID" > /tmp/rid.txt @@ -37,21 +37,21 @@ RUN . /tmp/rid.txt && dotnet publish \ ############################################### # App stage # ############################################### -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_URLS=http://+:5000 ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - krb5-user \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache curl \ + krb5 \ + icu-libs \ + shadow \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu # Copy app from the build stage WORKDIR /app diff --git a/src/Identity/IdentityServer/ApiClient.cs b/src/Identity/IdentityServer/ApiClient.cs index 5d768ae806..ead19813ec 100644 --- a/src/Identity/IdentityServer/ApiClient.cs +++ b/src/Identity/IdentityServer/ApiClient.cs @@ -1,4 +1,7 @@ -using Bit.Core.Settings; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Settings; using Bit.Identity.IdentityServer.RequestValidators; using Duende.IdentityServer.Models; @@ -15,10 +18,18 @@ public class ApiClient : Client { ClientId = id; AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode, WebAuthnGrantValidator.GrantType }; - RefreshTokenExpiration = TokenExpiration.Sliding; + + // Use global setting: false = Sliding (default), true = Absolute + RefreshTokenExpiration = globalSettings.IdentityServer.ApplyAbsoluteExpirationOnRefreshToken + ? TokenExpiration.Absolute + : TokenExpiration.Sliding; + RefreshTokenUsage = TokenUsage.ReUse; - SlidingRefreshTokenLifetime = 86400 * refreshTokenSlidingDays; - AbsoluteRefreshTokenLifetime = 0; // forever + + // Use global setting if provided, otherwise use constructor parameter + SlidingRefreshTokenLifetime = globalSettings.IdentityServer.SlidingRefreshTokenLifetimeSeconds ?? (86400 * refreshTokenSlidingDays); + AbsoluteRefreshTokenLifetime = globalSettings.IdentityServer.AbsoluteRefreshTokenLifetimeSeconds ?? 0; // forever + UpdateAccessTokenClaimsOnRefresh = true; AccessTokenLifetime = 3600 * accessTokenLifetimeHours; AllowOfflineAccess = true; diff --git a/src/Identity/IdentityServer/ApiResources.cs b/src/Identity/IdentityServer/ApiResources.cs index f969d67908..d225a7ea33 100644 --- a/src/Identity/IdentityServer/ApiResources.cs +++ b/src/Identity/IdentityServer/ApiResources.cs @@ -1,7 +1,7 @@ -using Bit.Core.Identity; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; +using Duende.IdentityModel; using Duende.IdentityServer.Models; -using IdentityModel; namespace Bit.Identity.IdentityServer; @@ -25,8 +25,12 @@ public class ApiResources Claims.OrganizationCustom, Claims.ProviderAdmin, Claims.ProviderServiceUser, - Claims.SecretsManagerAccess, + Claims.SecretsManagerAccess }), + new(ApiScopes.ApiSendAccess, [ + JwtClaimTypes.Subject, + Claims.SendAccessClaims.SendId + ]), new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }), new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }), new(ApiScopes.ApiLicensing, new[] { JwtClaimTypes.Subject }), diff --git a/src/Identity/IdentityServer/AuthorizationCodeStore.cs b/src/Identity/IdentityServer/AuthorizationCodeStore.cs index 8215532ba8..17827e818f 100644 --- a/src/Identity/IdentityServer/AuthorizationCodeStore.cs +++ b/src/Identity/IdentityServer/AuthorizationCodeStore.cs @@ -1,4 +1,7 @@ -using Duende.IdentityServer; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Duende.IdentityServer; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; diff --git a/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs index a7e2754f00..566b0395b8 100644 --- a/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs @@ -1,7 +1,10 @@ -using Bit.Core.IdentityServer; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.IdentityServer; using Bit.Core.Platform.Installations; +using Duende.IdentityModel; using Duende.IdentityServer.Models; -using IdentityModel; namespace Bit.Identity.IdentityServer.ClientProviders; diff --git a/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs index 6d7fdc3459..70c1e2e06a 100644 --- a/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs @@ -1,10 +1,10 @@ #nullable enable using System.Diagnostics; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Settings; +using Duende.IdentityModel; using Duende.IdentityServer.Models; -using IdentityModel; namespace Bit.Identity.IdentityServer.ClientProviders; diff --git a/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs index 76842a9e54..86a1272496 100644 --- a/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs @@ -1,9 +1,12 @@ -using Bit.Core.Enums; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; using Bit.Core.Repositories; +using Duende.IdentityModel; using Duende.IdentityServer.Models; -using IdentityModel; namespace Bit.Identity.IdentityServer.ClientProviders; diff --git a/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs b/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs index dec5f8dc64..628163ae74 100644 --- a/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs @@ -1,9 +1,12 @@ -using Bit.Core.Identity; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Identity; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Models.Data; using Bit.Core.SecretsManager.Repositories; +using Duende.IdentityModel; using Duende.IdentityServer.Models; -using IdentityModel; namespace Bit.Identity.IdentityServer.ClientProviders; diff --git a/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs index 82abfa3536..2d380acdf6 100644 --- a/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs @@ -3,13 +3,13 @@ using System.Collections.ObjectModel; using System.Security.Claims; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; +using Bit.Core.Billing.Services; using Bit.Core.Context; -using Bit.Core.Identity; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Utilities; +using Duende.IdentityModel; using Duende.IdentityServer.Models; -using IdentityModel; namespace Bit.Identity.IdentityServer.ClientProviders; diff --git a/src/Identity/IdentityServer/CustomValidatorRequestContext.cs b/src/Identity/IdentityServer/CustomValidatorRequestContext.cs index eb441e7941..a709a47cb2 100644 --- a/src/Identity/IdentityServer/CustomValidatorRequestContext.cs +++ b/src/Identity/IdentityServer/CustomValidatorRequestContext.cs @@ -1,4 +1,8 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Entities; +using Bit.Core.Entities; using Duende.IdentityServer.Validation; namespace Bit.Identity.IdentityServer; @@ -38,4 +42,10 @@ public class CustomValidatorRequestContext /// This will be null if the authentication request is successful. /// public Dictionary CustomResponse { get; set; } + + /// + /// A validated auth request + /// + /// + public AuthRequest ValidatedAuthRequest { get; set; } } diff --git a/src/Identity/IdentityServer/DynamicClientStore.cs b/src/Identity/IdentityServer/DynamicClientStore.cs index 9d7764bf42..d7e589a093 100644 --- a/src/Identity/IdentityServer/DynamicClientStore.cs +++ b/src/Identity/IdentityServer/DynamicClientStore.cs @@ -37,7 +37,7 @@ internal class DynamicClientStore : IClientStore if (firstPeriod == -1) { // No splitter, attempt but don't fail for a static client - if (_staticClientStore.ApiClients.TryGetValue(clientId, out var client)) + if (_staticClientStore.Clients.TryGetValue(clientId, out var client)) { return Task.FromResult(client); } diff --git a/src/Identity/IdentityServer/Enums/CustomGrantTypes.cs b/src/Identity/IdentityServer/Enums/CustomGrantTypes.cs new file mode 100644 index 0000000000..7203386bc5 --- /dev/null +++ b/src/Identity/IdentityServer/Enums/CustomGrantTypes.cs @@ -0,0 +1,11 @@ +namespace Bit.Identity.IdentityServer.Enums; + +/// +/// A class containing custom grant types used in the Bitwarden IdentityServer implementation +/// +public static class CustomGrantTypes +{ + public const string SendAccess = "send_access"; + // TODO: PM-24471 replace magic string with a constant for webauthn + public const string WebAuthn = "webauthn"; +} diff --git a/src/Identity/IdentityServer/Enums/DeviceValidationResultType.cs b/src/Identity/IdentityServer/Enums/DeviceValidationResultType.cs index 45c901e306..f8d04eccfb 100644 --- a/src/Identity/IdentityServer/Enums/DeviceValidationResultType.cs +++ b/src/Identity/IdentityServer/Enums/DeviceValidationResultType.cs @@ -6,5 +6,6 @@ public enum DeviceValidationResultType : byte InvalidUser = 1, InvalidNewDeviceOtp = 2, NewDeviceVerificationRequired = 3, - NoDeviceInformationProvided = 4 + NoDeviceInformationProvided = 4, + AuthRequestFlowUnknownDevice = 5, } diff --git a/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs index dad9d8e27d..fece7b10b4 100644 --- a/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs +++ b/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Entities; diff --git a/src/Identity/IdentityServer/PersistedGrantStore.cs b/src/Identity/IdentityServer/PersistedGrantStore.cs index 70d778430a..b6bdaccc53 100644 --- a/src/Identity/IdentityServer/PersistedGrantStore.cs +++ b/src/Identity/IdentityServer/PersistedGrantStore.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Duende.IdentityServer.Models; using Duende.IdentityServer.Stores; diff --git a/src/Identity/IdentityServer/ProfileService.cs b/src/Identity/IdentityServer/ProfileService.cs index d7d6708374..9ea8fcf471 100644 --- a/src/Identity/IdentityServer/ProfileService.cs +++ b/src/Identity/IdentityServer/ProfileService.cs @@ -1,7 +1,9 @@ using System.Security.Claims; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; +using Bit.Core.Billing.Services; using Bit.Core.Context; -using Bit.Core.Identity; +using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; @@ -38,8 +40,22 @@ public class ProfileService : IProfileService public async Task GetProfileDataAsync(ProfileDataRequestContext context) { var existingClaims = context.Subject.Claims; - var newClaims = new List(); + // If the client is a Send client, we do not add any additional claims + if (context.Client.ClientId == BitwardenClient.Send) + { + // preserve all claims that were already on context.Subject + // which includes the ones added by the SendAccessGrantValidator + context.IssuedClaims.AddRange(existingClaims); + return; + } + + // Whenever IdentityServer issues a new access token or services a UserInfo request, it calls + // GetProfileDataAsync to determine which claims to include in the token or response. + // In normal user identity scenarios, we have to look up the user to get their claims and update + // the issued claims collection as claim info can have changed since the last time the user logged in or the + // last time the token was issued. + var newClaims = new List(); var user = await _userService.GetUserByPrincipalAsync(context.Subject); if (user != null) { @@ -59,12 +75,16 @@ public class ProfileService : IProfileService // filter out any of the new claims var existingClaimsToKeep = existingClaims - .Where(c => !c.Type.StartsWith("org") && - (newClaims.Count == 0 || !newClaims.Any(nc => nc.Type == c.Type))) - .ToList(); + .Where(c => + // Drop any org claims + !c.Type.StartsWith("org") && + // If we have no new claims, then keep the existing claims + // If we have new claims, then keep the existing claim if it does not match a new claim type + (newClaims.Count == 0 || !newClaims.Any(nc => nc.Type == c.Type)) + ).ToList(); newClaims.AddRange(existingClaimsToKeep); - if (newClaims.Any()) + if (newClaims.Count != 0) { context.IssuedClaims.AddRange(newClaims); } @@ -72,6 +92,13 @@ public class ProfileService : IProfileService public async Task IsActiveAsync(IsActiveContext context) { + // Send Tokens are not refreshed so when the token has expired the user must request a new one via the authentication method assigned to the send. + if (context.Client.ClientId == BitwardenClient.Send) + { + context.IsActive = true; + return; + } + // We add the security stamp claim to the persisted grant when we issue the refresh token. // IdentityServer will add this claim to the subject, and here we evaluate whether the security stamp that // was persisted matches the current security stamp of the user. If it does not match, then the user has performed diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index dd4592aa0d..e57ed1c85f 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -1,16 +1,19 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Core; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Models.Api; using Bit.Core.Models.Api.Response; using Bit.Core.Repositories; @@ -32,6 +35,8 @@ public abstract class BaseRequestValidator where T : class private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; private readonly IUserRepository _userRepository; + private readonly IAuthRequestRepository _authRequestRepository; + private readonly IMailService _mailService; protected ICurrentContext CurrentContext { get; } protected IPolicyService PolicyService { get; } @@ -56,7 +61,10 @@ public abstract class BaseRequestValidator where T : class IFeatureService featureService, ISsoConfigRepository ssoConfigRepository, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder, - IPolicyRequirementQuery policyRequirementQuery) + IPolicyRequirementQuery policyRequirementQuery, + IAuthRequestRepository authRequestRepository, + IMailService mailService + ) { _userManager = userManager; _userService = userService; @@ -73,6 +81,8 @@ public abstract class BaseRequestValidator where T : class SsoConfigRepository = ssoConfigRepository; UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder; PolicyRequirementQuery = policyRequirementQuery; + _authRequestRepository = authRequestRepository; + _mailService = mailService; } protected async Task ValidateAsync(T context, ValidatedTokenRequest request, @@ -153,6 +163,7 @@ public abstract class BaseRequestValidator where T : class } else { + await SendFailedTwoFactorEmail(user, twoFactorProviderType); await UpdateFailedAuthDetailsAsync(user); await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user); } @@ -187,6 +198,14 @@ public abstract class BaseRequestValidator where T : class return; } + // TODO: PM-24324 - This should be its own validator at some point. + // 6. Auth request handling + if (validatorContext.ValidatedAuthRequest != null) + { + validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow; + await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest); + } + await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken); } @@ -358,6 +377,14 @@ public abstract class BaseRequestValidator where T : class await _userRepository.ReplaceAsync(user); } + private async Task SendFailedTwoFactorEmail(User user, TwoFactorProviderType failedAttemptType) + { + if (FeatureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail)) + { + await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow, CurrentContext.IpAddress); + } + } + private async Task GetMasterPasswordPolicyAsync(User user) { // Check current context/cache to see if user is in any organizations, avoids extra DB call if not @@ -401,8 +428,8 @@ public abstract class BaseRequestValidator where T : class /// /// Builds the custom response that will be sent to the client upon successful authentication, which /// includes the information needed for the client to initialize the user's account in state. - /// - /// The authenticated user. + /// + /// The authenticated user. /// The current request context. /// The device used for authentication. /// Whether to send a 2FA remember token. diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 7d468fafa8..1495973b80 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -3,19 +3,19 @@ using System.Security.Claims; using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; -using Bit.Core.IdentityServer; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Duende.IdentityModel; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Validation; using HandlebarsDotNet; -using IdentityModel; using Microsoft.AspNetCore.Identity; #nullable enable @@ -45,7 +45,9 @@ public class CustomTokenRequestValidator : BaseRequestValidator { { "encrypted_payload", payload } }; } - if (FeatureService.IsEnabled(FeatureFlagKeys.RecordInstallationLastActivityDate) - && context.Result.ValidatedRequest.ClientId.StartsWith("installation")) + if (context.Result.ValidatedRequest.ClientId.StartsWith("installation")) { - var installationIdPart = clientId.Split(".")[1]; await RecordActivityForInstallation(clientId.Split(".")[1]); } return; diff --git a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs index 4dc77c4449..d9a4fdb485 100644 --- a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs @@ -1,6 +1,10 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Reflection; using Bit.Core; +using Bit.Core.Auth.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -9,6 +13,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Identity.IdentityServer.Enums; +using Duende.IdentityServer.Models; using Duende.IdentityServer.Validation; using Microsoft.Extensions.Caching.Distributed; @@ -22,6 +27,7 @@ public class DeviceValidator( ICurrentContext currentContext, IUserService userService, IDistributedCache distributedCache, + ITwoFactorEmailService twoFactorEmailService, ILogger logger) : IDeviceValidator { private readonly IDeviceService _deviceService = deviceService; @@ -32,6 +38,9 @@ public class DeviceValidator( private readonly IUserService _userService = userService; private readonly IDistributedCache distributedCache = distributedCache; private readonly ILogger _logger = logger; + private readonly ITwoFactorEmailService _twoFactorEmailService = twoFactorEmailService; + + private const string PasswordGrantType = "password"; public async Task ValidateRequestDeviceAsync(ValidatedTokenRequest request, CustomValidatorRequestContext context) { @@ -47,8 +56,10 @@ public class DeviceValidator( return false; } - // if not a new device request then check if the device is known - if (!NewDeviceOtpRequest(request)) + // Check if the request has a NewDeviceOtp, if it does we can assume it is an unknown device + // that has already been prompted for new device verification so we don't + // have to hit the database to check if the device is known to avoid unnecessary database calls. + if (!RequestHasNewDeviceVerificationOtp(request)) { var knownDevice = await GetKnownDeviceAsync(context.User, requestDevice); // if the device is know then we return the device fetched from the database @@ -61,11 +72,24 @@ public class DeviceValidator( } } - // We have established that the device is unknown at this point; begin new device verification - if (request.GrantType == "password" && - request.Raw["AuthRequest"] == null && - !context.TwoFactorRequired && - !context.SsoRequired && + // The device is either unknown or the request has a NewDeviceOtp (implies unknown device) + + var rawAuthRequestId = request.Raw["AuthRequest"]?.ToLowerInvariant(); + var isAuthRequest = !string.IsNullOrEmpty(rawAuthRequestId); + + // Device unknown, but if we are in an auth request flow, this is not valid + // as we only support auth request authN requests on known devices + // Note: we re-use the resource owner password flow for auth requests + if (request.GrantType == GrantType.ResourceOwnerPassword && isAuthRequest) + { + (context.ValidationErrorResult, context.CustomResponse) = + BuildDeviceErrorResult(DeviceValidationResultType.AuthRequestFlowUnknownDevice); + return false; + } + + // Enforce new device verification for resource owner password flow (just normal password flow) + if (request.GrantType == GrantType.ResourceOwnerPassword && + context is { TwoFactorRequired: false, SsoRequired: false } && _globalSettings.EnableNewDeviceVerification) { var validationResult = await HandleNewDeviceVerificationAsync(context.User, request); @@ -75,7 +99,7 @@ public class DeviceValidator( BuildDeviceErrorResult(validationResult); if (validationResult == DeviceValidationResultType.NewDeviceVerificationRequired) { - await _userService.SendNewDeviceVerificationEmailAsync(context.User); + await _twoFactorEmailService.SendNewDeviceVerificationEmailAsync(context.User); } return false; } @@ -226,7 +250,7 @@ public class DeviceValidator( /// /// /// - public static bool NewDeviceOtpRequest(ValidatedRequest request) + public static bool RequestHasNewDeviceVerificationOtp(ValidatedRequest request) { return !string.IsNullOrEmpty(request.Raw["NewDeviceOtp"]?.ToString()); } @@ -246,7 +270,7 @@ public class DeviceValidator( var customResponse = new Dictionary(); switch (errorType) { - /* + /* * The ErrorMessage is brittle and is used to control the flow in the clients. Do not change them without updating the client as well. * There is a backwards compatibility issue as well: if you make a change on the clients then ensure that they are backwards * compatible. @@ -267,6 +291,10 @@ public class DeviceValidator( result.ErrorDescription = "No device information provided"; customResponse.Add("ErrorModel", new ErrorResponseModel("no device information provided")); break; + case DeviceValidationResultType.AuthRequestFlowUnknownDevice: + result.ErrorDescription = "Auth requests are not supported on unknown devices"; + customResponse.Add("ErrorModel", new ErrorResponseModel("auth request flow unsupported on unknown device")); + break; } return (result, customResponse); } diff --git a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs index c30c94eeee..17592cc0c1 100644 --- a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs @@ -1,4 +1,7 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; @@ -8,7 +11,6 @@ using Bit.Core.Entities; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Utilities; using Duende.IdentityServer.Models; using Duende.IdentityServer.Validation; using Microsoft.AspNetCore.Identity; @@ -38,7 +40,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator where T : SendAuthenticationMethod +{ + /// + /// + /// request context + /// SendAuthenticationRecord that contains the information to be compared against the context + /// the sendId being accessed + /// returns the result of the validation; A failed result will be an error a successful will contain the claims and a success + Task ValidateRequestAsync(ExtensionGrantValidationContext context, T authMethod, Guid sendId); +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs new file mode 100644 index 0000000000..1f5bfba244 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs @@ -0,0 +1,118 @@ +using Bit.Core.Auth.Identity.TokenProviders; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +/// +/// String constants for the Send Access user feature +/// Most of these need to be synced with the `bitwarden-auth` crate in the SDK. +/// There is snapshot testing to help ensure this. +/// +public static class SendAccessConstants +{ + /// + /// A catch all error type for send access related errors. Used mainly in the + /// + public const string SendAccessError = "send_access_error_type"; + public static class TokenRequest + { + /// + /// used to fetch Send from database. + /// + public const string SendId = "send_id"; + /// + /// used to validate Send protected passwords + /// + public const string ClientB64HashedPassword = "password_hash_b64"; + /// + /// email used to see if email is associated with the Send + /// + public const string Email = "email"; + /// + /// Otp code sent to email associated with the Send + /// + public const string Otp = "otp"; + } + + public static class SendIdGuidValidatorResults + { + /// + /// The in the request is a valid GUID and the request is well formed. Not returned in any response. + /// + public const string ValidSendGuid = "valid_send_guid"; + /// + /// The is missing from the request. + /// + public const string SendIdRequired = "send_id_required"; + /// + /// The is invalid, does not match a known send. + /// + public const string InvalidSendId = "send_id_invalid"; + } + + public static class PasswordValidatorResults + { + /// + /// The does not match the send's password hash. + /// + public const string RequestPasswordDoesNotMatch = "password_hash_b64_invalid"; + /// + /// The is missing from the request. + /// + public const string RequestPasswordIsRequired = "password_hash_b64_required"; + } + + public static class EmailOtpValidatorResults + { + /// + /// Represents the error code indicating that an email address is required. + /// + public const string EmailRequired = "email_required"; + /// + /// Represents the error code indicating that an email address is invalid. + /// + public const string EmailInvalid = "email_invalid"; + /// + /// Represents the status indicating that both email and OTP are required, and the OTP has been sent. + /// + public const string EmailOtpSent = "email_and_otp_required_otp_sent"; + /// + /// Represents the status indicating that both email and OTP are required, and the OTP is invalid. + /// + public const string EmailOtpInvalid = "otp_invalid"; + /// + /// For what ever reason the OTP was not able to be generated + /// + public const string OtpGenerationFailed = "otp_generation_failed"; + } + + /// + /// These are the constants for the OTP token that is generated during the email otp authentication process. + /// These items are required by to aid in the creation of a unique lookup key. + /// Look up key format is: {TokenProviderName}_{Purpose}_{TokenUniqueIdentifier} + /// + public static class OtpToken + { + public const string TokenProviderName = "send_access"; + public const string Purpose = "email_otp"; + /// + /// This will be send_id {0} and email {1} + /// + public const string TokenUniqueIdentifier = "{0}_{1}"; + } + + public static class OtpEmail + { + public const string Subject = "Your Bitwarden Send verification code is {0}"; + } + + /// + /// We use these static strings to help guide the enumeration protection logic. + /// + public static class EnumerationProtection + { + public const string Guid = "guid"; + public const string Password = "password"; + public const string Email = "email"; + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs new file mode 100644 index 0000000000..101c6952f3 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -0,0 +1,148 @@ +using System.Security.Claims; +using Bit.Core; +using Bit.Core.Auth.Identity; +using Bit.Core.Services; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.SendFeatures.Queries.Interfaces; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +public class SendAccessGrantValidator( + ISendAuthenticationQuery _sendAuthenticationQuery, + ISendAuthenticationMethodValidator _sendNeverAuthenticateValidator, + ISendAuthenticationMethodValidator _sendPasswordRequestValidator, + ISendAuthenticationMethodValidator _sendEmailOtpRequestValidator, + IFeatureService _featureService) : IExtensionGrantValidator +{ + string IExtensionGrantValidator.GrantType => CustomGrantTypes.SendAccess; + + private static readonly Dictionary _sendGrantValidatorErrorDescriptions = new() + { + { SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." }, + { SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." } + }; + + public async Task ValidateAsync(ExtensionGrantValidationContext context) + { + // Check the feature flag + if (!_featureService.IsEnabled(FeatureFlagKeys.SendAccess)) + { + context.Result = new GrantValidationResult(TokenRequestErrors.UnsupportedGrantType); + return; + } + + var (sendIdGuid, result) = GetRequestSendId(context); + if (result != SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid) + { + context.Result = BuildErrorResult(result); + return; + } + + // Look up send by id + var method = await _sendAuthenticationQuery.GetAuthenticationMethod(sendIdGuid); + + switch (method) + { + case NeverAuthenticate never: + // null send scenario. + context.Result = await _sendNeverAuthenticateValidator.ValidateRequestAsync(context, never, sendIdGuid); + return; + case NotAuthenticated: + // automatically issue access token + context.Result = BuildBaseSuccessResult(sendIdGuid); + return; + case ResourcePassword rp: + // Validate if the password is correct, or if we need to respond with a 400 stating a password is invalid or required. + context.Result = await _sendPasswordRequestValidator.ValidateRequestAsync(context, rp, sendIdGuid); + return; + case EmailOtp eo: + // Validate if the request has the correct email and OTP. If not, respond with a 400 and information about the failure. + context.Result = await _sendEmailOtpRequestValidator.ValidateRequestAsync(context, eo, sendIdGuid); + return; + default: + // shouldn’t ever hit this + throw new InvalidOperationException($"Unknown auth method: {method.GetType()}"); + } + } + + /// + /// tries to parse the send_id from the request. + /// If it is not present or invalid, sets the correct result error. + /// + /// request context + /// a parsed sendId Guid and success result or a Guid.Empty and error type otherwise + private static (Guid, string) GetRequestSendId(ExtensionGrantValidationContext context) + { + var request = context.Request.Raw; + var sendId = request.Get(SendAccessConstants.TokenRequest.SendId); + + // if the sendId is null then the request is the wrong shape and the request is invalid + if (sendId == null) + { + return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired); + } + // the send_id is not null so the request is the correct shape, so we will attempt to parse it + try + { + var guidBytes = CoreHelpers.Base64UrlDecode(sendId); + var sendGuid = new Guid(guidBytes); + // Guid.Empty indicates an invalid send_id return invalid grant + if (sendGuid == Guid.Empty) + { + return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); + } + return (sendGuid, SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid); + } + catch + { + return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); + } + } + + /// + /// Builds an error result for the specified error type. + /// + /// This error is a constant string from + /// The error result. + private static GrantValidationResult BuildErrorResult(string error) + { + var customResponse = new Dictionary + { + { SendAccessConstants.SendAccessError, error } + }; + + return error switch + { + // Request is the wrong shape + SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired => new GrantValidationResult( + TokenRequestErrors.InvalidRequest, + errorDescription: _sendGrantValidatorErrorDescriptions[error], + customResponse), + // Request is correct shape but data is bad + SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId => new GrantValidationResult( + TokenRequestErrors.InvalidGrant, + errorDescription: _sendGrantValidatorErrorDescriptions[error], + customResponse), + // should never get here + _ => new GrantValidationResult(TokenRequestErrors.InvalidRequest) + }; + } + + private static GrantValidationResult BuildBaseSuccessResult(Guid sendId) + { + var claims = new List + { + new(Claims.SendAccessClaims.SendId, sendId.ToString()), + new(Claims.Type, IdentityClientType.Send.ToString()) + }; + + return new GrantValidationResult( + subject: sendId.ToString(), + authenticationMethod: CustomGrantTypes.SendAccess, + claims: claims); + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs new file mode 100644 index 0000000000..ca48c4fbec --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs @@ -0,0 +1,134 @@ +using System.Security.Claims; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Services; +using Bit.Core.Tools.Models.Data; +using Bit.Identity.IdentityServer.Enums; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +public class SendEmailOtpRequestValidator( + IOtpTokenProvider otpTokenProvider, + IMailService mailService) : ISendAuthenticationMethodValidator +{ + + /// + /// static object that contains the error messages for the SendEmailOtpRequestValidator. + /// + private static readonly Dictionary _sendEmailOtpValidatorErrorDescriptions = new() + { + { SendAccessConstants.EmailOtpValidatorResults.EmailRequired, $"{SendAccessConstants.TokenRequest.Email} is required." }, + { SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent, "email otp sent." }, + { SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, $"{SendAccessConstants.TokenRequest.Email} is invalid." }, + { SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid, $"{SendAccessConstants.TokenRequest.Email} otp is invalid." }, + }; + + public async Task ValidateRequestAsync(ExtensionGrantValidationContext context, EmailOtp authMethod, Guid sendId) + { + var request = context.Request.Raw; + // get email + var email = request.Get(SendAccessConstants.TokenRequest.Email); + + // It is an invalid request if the email is missing which indicated bad shape. + if (string.IsNullOrEmpty(email)) + { + // Request is the wrong shape and doesn't contain an email field. + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired); + } + + // email must be in the list of emails in the EmailOtp array + if (!authMethod.Emails.Contains(email)) + { + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid); + } + + // get otp from request + var requestOtp = request.Get(SendAccessConstants.TokenRequest.Otp); + var uniqueIdentifierForTokenCache = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + if (string.IsNullOrEmpty(requestOtp)) + { + // Since the request doesn't have an OTP, generate one + var token = await otpTokenProvider.GenerateTokenAsync( + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + uniqueIdentifierForTokenCache); + + // Verify that the OTP is generated + if (string.IsNullOrEmpty(token)) + { + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed); + } + + await mailService.SendSendEmailOtpEmailAsync( + email, + token, + string.Format(SendAccessConstants.OtpEmail.Subject, token)); + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent); + } + + // validate request otp + var otpResult = await otpTokenProvider.ValidateTokenAsync( + requestOtp, + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + uniqueIdentifierForTokenCache); + + // If OTP is invalid return error result + if (!otpResult) + { + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid); + } + + return BuildSuccessResult(sendId, email!); + } + + private static GrantValidationResult BuildErrorResult(string error) + { + switch (error) + { + case SendAccessConstants.EmailOtpValidatorResults.EmailRequired: + case SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent: + return new GrantValidationResult(TokenRequestErrors.InvalidRequest, + errorDescription: _sendEmailOtpValidatorErrorDescriptions[error], + new Dictionary + { + { SendAccessConstants.SendAccessError, error } + }); + case SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid: + case SendAccessConstants.EmailOtpValidatorResults.EmailInvalid: + return new GrantValidationResult( + TokenRequestErrors.InvalidGrant, + errorDescription: _sendEmailOtpValidatorErrorDescriptions[error], + new Dictionary + { + { SendAccessConstants.SendAccessError, error } + }); + default: + return new GrantValidationResult( + TokenRequestErrors.InvalidRequest, + errorDescription: error); + } + } + + /// + /// Builds a successful validation result for the Send password send_access grant. + /// + /// Guid of the send being accessed. + /// successful grant validation result + private static GrantValidationResult BuildSuccessResult(Guid sendId, string email) + { + var claims = new List + { + new(Claims.SendAccessClaims.SendId, sendId.ToString()), + new(Claims.SendAccessClaims.Email, email), + new(Claims.Type, IdentityClientType.Send.ToString()) + }; + + return new GrantValidationResult( + subject: sendId.ToString(), + authenticationMethod: CustomGrantTypes.SendAccess, + claims: claims); + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs new file mode 100644 index 0000000000..36e033360f --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs @@ -0,0 +1,87 @@ +using System.Text; +using Bit.Core.Settings; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Utilities; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +/// +/// This class is used to protect our system from enumeration attacks. This Validator will always return an error result. +/// We hash the SendId Guid passed into the request to select the an error from the list of possible errors. This ensures +/// that the same error is always returned for the same SendId. +/// +/// We need access to a hash key to generate the error index. +public class SendNeverAuthenticateRequestValidator(GlobalSettings globalSettings) : ISendAuthenticationMethodValidator +{ + private readonly string[] _errorOptions = + [ + SendAccessConstants.EnumerationProtection.Guid, + SendAccessConstants.EnumerationProtection.Password, + SendAccessConstants.EnumerationProtection.Email + ]; + + public Task ValidateRequestAsync( + ExtensionGrantValidationContext context, + NeverAuthenticate authMethod, + Guid sendId) + { + var neverAuthenticateError = GetErrorIndex(sendId, _errorOptions.Length); + var request = context.Request.Raw; + var errorType = neverAuthenticateError; + + switch (neverAuthenticateError) + { + case SendAccessConstants.EnumerationProtection.Guid: + errorType = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId; + break; + case SendAccessConstants.EnumerationProtection.Email: + var hasEmail = request.Get(SendAccessConstants.TokenRequest.Email) is not null; + errorType = hasEmail ? SendAccessConstants.EmailOtpValidatorResults.EmailInvalid + : SendAccessConstants.EmailOtpValidatorResults.EmailRequired; + break; + case SendAccessConstants.EnumerationProtection.Password: + var hasPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword) is not null; + errorType = hasPassword ? SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch + : SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired; + break; + } + + return Task.FromResult(BuildErrorResult(errorType)); + } + + private static GrantValidationResult BuildErrorResult(string errorType) + { + // Create error response with custom response data + var customResponse = new Dictionary + { + { SendAccessConstants.SendAccessError, errorType } + }; + + var requestError = errorType switch + { + SendAccessConstants.EnumerationProtection.Guid => TokenRequestErrors.InvalidGrant, + SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired => TokenRequestErrors.InvalidGrant, + SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch => TokenRequestErrors.InvalidRequest, + SendAccessConstants.EmailOtpValidatorResults.EmailInvalid => TokenRequestErrors.InvalidGrant, + SendAccessConstants.EmailOtpValidatorResults.EmailRequired => TokenRequestErrors.InvalidRequest, + _ => TokenRequestErrors.InvalidGrant + }; + + return new GrantValidationResult(requestError, errorType, customResponse); + } + + private string GetErrorIndex(Guid sendId, int range) + { + var salt = sendId.ToString(); + byte[] hmacKey = []; + if (CoreHelpers.SettingHasValue(globalSettings.SendDefaultHashKey)) + { + hmacKey = Encoding.UTF8.GetBytes(globalSettings.SendDefaultHashKey); + } + + var index = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + return _errorOptions[index]; + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs new file mode 100644 index 0000000000..a514e3bc8b --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs @@ -0,0 +1,79 @@ +using System.Security.Claims; +using Bit.Core.Auth.Identity; +using Bit.Core.KeyManagement.Sends; +using Bit.Core.Tools.Models.Data; +using Bit.Identity.IdentityServer.Enums; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendAuthenticationMethodValidator +{ + private readonly ISendPasswordHasher _sendPasswordHasher = sendPasswordHasher; + + /// + /// static object that contains the error messages for the SendPasswordRequestValidator. + /// + private static readonly Dictionary _sendPasswordValidatorErrorDescriptions = new() + { + { SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch, $"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is invalid." }, + { SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, $"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required." } + }; + + public Task ValidateRequestAsync(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId) + { + var request = context.Request.Raw; + var clientHashedPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword); + + // It is an invalid request _only_ if the passwordHashB64 is missing which indicated bad shape. + if (clientHashedPassword == null) + { + // Request is the wrong shape and doesn't contain a passwordHashB64 field. + return Task.FromResult(new GrantValidationResult( + TokenRequestErrors.InvalidRequest, + errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired], + new Dictionary + { + { SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired } + })); + } + + // _sendPasswordHasher.PasswordHashMatches checks for an empty string so no need to do it before we make the call. + var hashMatches = _sendPasswordHasher.PasswordHashMatches( + resourcePassword.Hash, clientHashedPassword); + + if (!hashMatches) + { + // Request is the correct shape but the passwordHashB64 doesn't match, hash could be empty. + return Task.FromResult(new GrantValidationResult( + TokenRequestErrors.InvalidGrant, + errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch], + new Dictionary + { + { SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch } + })); + } + + return Task.FromResult(BuildSendPasswordSuccessResult(sendId)); + } + + /// + /// Builds a successful validation result for the Send password send_access grant. + /// + /// + /// + private static GrantValidationResult BuildSendPasswordSuccessResult(Guid sendId) + { + var claims = new List + { + new(Claims.SendAccessClaims.SendId, sendId.ToString()), + new(Claims.Type, IdentityClientType.Send.ToString()) + }; + + return new GrantValidationResult( + subject: sendId.ToString(), + authenticationMethod: CustomGrantTypes.SendAccess, + claims: claims); + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md b/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md new file mode 100644 index 0000000000..afab13a156 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md @@ -0,0 +1,66 @@ +Send Access Request Validation +=== + +This feature supports the ability of Tools to require specific claims for access to sends. + +In order to access Send data a user must meet the requirements laid out in these request validators. + +# ***Important: String Constants*** + +The string constants contained herein are used in conjunction with the Auth module in the SDK. Any change to these string values _must_ be intentional and _must_ have a corresponding change in the SDK. + +There is snapshot testing that will fail if the strings change to help detect unintended changes to the string constants. + +# Custom Claims + +Send access tokens contain custom claims specific to the Send the Send grant type. + +1. `send_id` - is always included in the issued access token. This is the `GUID` of the request Send. +1. `send_email` - only set when the Send requires `EmailOtp` authentication type. +1. `type` - this will always be `Send` + +# Authentication methods + +## `NeverAuthenticate` + +For a Send to be in this state two things can be true: +1. The Send has been modified and no longer allows access. +2. The Send does not exist. + +## `NotAuthenticated` + +In this scenario the Send is not protected by any added authentication or authorization and the access token is issued to the requesting user. + +## `ResourcePassword` + +In this scenario the Send is password protected and a user must supply the correct password hash to be issued an access token. + +## `EmailOtp` + +In this scenario the Send is only accessible to owners of specific email addresses. The user must submit a correct email. Once the email has been entered then ownership of the email must be established via OTP. The Otp is sent to the aforementioned email and must be supplied, along with the email, to be issued an access token. + +# Send Access Request Validation + +## Required Parameters + +### All Requests +- `send_id` - Base64 URL-encoded GUID of the send being accessed + +### Password Protected Sends +- `password_hash_b64` - client hashed Base64-encoded password. + +### Email OTP Protected Sends +- `email` - Email address associated with the send +- `otp` - One-time password (optional - if missing, OTP is generated and sent) + +## Error Responses + +All errors include a custom response field: +```json +{ + "error": "invalid_request|invalid_grant", + "error_description": "Human readable description", + "send_access_error_type": "specific_error_code" +} +``` + diff --git a/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs b/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs index e4c1ebd15e..1247feac21 100644 --- a/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity.TokenProviders; diff --git a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs index 76949eb5f7..e679c48433 100644 --- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs @@ -1,4 +1,7 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using System.Text.Json; using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -45,7 +48,9 @@ public class WebAuthnGrantValidator : BaseRequestValidator + Clients = new List { new ApiClient(globalSettings, BitwardenClient.Mobile, 60, 1), new ApiClient(globalSettings, BitwardenClient.Web, 7, 1), new ApiClient(globalSettings, BitwardenClient.Browser, 30, 1), new ApiClient(globalSettings, BitwardenClient.Desktop, 30, 1), new ApiClient(globalSettings, BitwardenClient.Cli, 30, 1), - new ApiClient(globalSettings, BitwardenClient.DirectoryConnector, 30, 24) + new ApiClient(globalSettings, BitwardenClient.DirectoryConnector, 30, 24), + SendClientBuilder.Build(globalSettings), }.ToFrozenDictionary(c => c.ClientId); } - public FrozenDictionary ApiClients { get; } + public FrozenDictionary Clients { get; } } diff --git a/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs b/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs new file mode 100644 index 0000000000..6424316505 --- /dev/null +++ b/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs @@ -0,0 +1,31 @@ +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; +using Bit.Core.Settings; +using Bit.Identity.IdentityServer.Enums; +using Duende.IdentityServer.Models; + +namespace Bit.Identity.IdentityServer.StaticClients; +public static class SendClientBuilder +{ + public static Client Build(GlobalSettings globalSettings) + { + return new Client() + { + ClientId = BitwardenClient.Send, + AllowedGrantTypes = [CustomGrantTypes.SendAccess], + AccessTokenLifetime = 60 * globalSettings.SendAccessTokenLifetimeInMinutes, + + // Do not allow refresh tokens to be issued. + AllowOfflineAccess = false, + + // Send is a public anonymous client, so no secret is required (or really possible to use securely). + RequireClientSecret = false, + + // Allow web vault to use this client. + AllowedCorsOrigins = [globalSettings.BaseServiceUri.Vault], + + // Setup API scopes that the client can request in the scope property of the token request. + AllowedScopes = [ApiScopes.ApiSendAccess], + }; + } +} diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs index 61543f9751..136c3f7298 100644 --- a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs +++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs @@ -1,11 +1,14 @@ -using Bit.Core.Auth.Entities; +using Bit.Core; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Utilities; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Response; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Identity.Utilities; @@ -23,9 +26,10 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder private readonly IDeviceRepository _deviceRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ILoginApprovingClientTypes _loginApprovingClientTypes; + private readonly IFeatureService _featureService; private UserDecryptionOptions _options = new UserDecryptionOptions(); - private User? _user; + private User _user = null!; private SsoConfig? _ssoConfig; private Device? _device; @@ -33,18 +37,19 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder ICurrentContext currentContext, IDeviceRepository deviceRepository, IOrganizationUserRepository organizationUserRepository, - ILoginApprovingClientTypes loginApprovingClientTypes + ILoginApprovingClientTypes loginApprovingClientTypes, + IFeatureService featureService ) { _currentContext = currentContext; _deviceRepository = deviceRepository; _organizationUserRepository = organizationUserRepository; _loginApprovingClientTypes = loginApprovingClientTypes; + _featureService = featureService; } public IUserDecryptionOptionsBuilder ForUser(User user) { - _options.HasMasterPassword = user.HasMasterPassword(); _user = user; return this; } @@ -65,15 +70,18 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder { if (credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled) { - _options.WebAuthnPrfOption = new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey); + _options.WebAuthnPrfOption = + new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey); } + return this; } public async Task BuildAsync() { + BuildMasterPasswordUnlock(); BuildKeyConnectorOptions(); - await BuildTrustedDeviceOptions(); + await BuildTrustedDeviceOptionsAsync(); return _options; } @@ -86,13 +94,14 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder } var ssoConfigurationData = _ssoConfig.GetData(); - if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl)) + if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && + !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl)) { _options.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl); } } - private async Task BuildTrustedDeviceOptions() + private async Task BuildTrustedDeviceOptionsAsync() { // TrustedDeviceEncryption only exists for SSO, if that changes then these guards should change if (_ssoConfig == null) @@ -100,8 +109,9 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder return; } - var isTdeActive = _ssoConfig.GetData() is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }; - var isTdeOffboarding = _user != null && !_user.HasMasterPassword() && _device != null && _device.IsTrusted() && !isTdeActive; + var isTdeActive = _ssoConfig.GetData() is + { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }; + var isTdeOffboarding = !_user.HasMasterPassword() && _device != null && _device.IsTrusted() && !isTdeActive; if (!isTdeActive && !isTdeOffboarding) { return; @@ -116,35 +126,57 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder } var hasLoginApprovingDevice = false; - if (_device != null && _user != null) + if (_device != null) { var allDevices = await _deviceRepository.GetManyByUserIdAsync(_user.Id); - // Checks if the current user has any devices that are capable of approving login with device requests except for - // their current device. - // NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting. - hasLoginApprovingDevice = allDevices.Any(d => d.Identifier != _device.Identifier && _loginApprovingClientTypes.TypesThatCanApprove.Contains(DeviceTypes.ToClientType(d.Type))); + // Checks if the current user has any devices that are capable of approving login with device requests + // except for their current device. + hasLoginApprovingDevice = allDevices.Any(d => + d.Identifier != _device.Identifier && + _loginApprovingClientTypes.TypesThatCanApprove.Contains(DeviceTypes.ToClientType(d.Type))); } - // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP + // Just-in-time-provisioned users, which can include users invited to a TDE organization with SSO and granted + // the Admin/Owner role or Custom user role with ManageResetPassword permission, will not have claims available + // in context to reflect this permission if granted as part of an invite for the current organization. + // Therefore, as written today, CurrentContext will not surface those permissions for those users. + // In order to make this check accurate at first login for all applicable cases, we have to go back to the + // database record. + // In the TDE flow, the users will have been JIT-provisioned at SSO callback time, and the relationship between + // user and organization user will have been codified. + var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id); var hasManageResetPasswordPermission = false; - // when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here - if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId)) + if (_featureService.IsEnabled(FeatureFlagKeys.PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword)) { - // TDE requires single org so grabbing first org & id is fine. - hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId); + hasManageResetPasswordPermission = await EvaluateHasManageResetPasswordPermission(); } - - var hasAdminApproval = false; - if (_user != null) + else { + // TODO: PM-26065 remove use of above feature flag from the server, and remove this branching logic, which + // has been replaced by EvaluateHasManageResetPasswordPermission. + // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP. + // When removing feature flags, please also see notes and removals intended for test suite in + // Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue. + + // when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here + if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId)) + { + // TDE requires single org so grabbing first org & id is fine. + hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId); + } + // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null - var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id); + + // NOTE: Commented from original impl because the organization user repository call has been hoisted to support + // branching paths through flagging. + //organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id); hasManageResetPasswordPermission |= organizationUser != null && (organizationUser.Type == OrganizationUserType.Owner || organizationUser.Type == OrganizationUserType.Admin); - // They are only able to be approved by an admin if they have enrolled is reset password - hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); } + // They are only able to be approved by an admin if they have enrolled is reset password + var hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); + _options.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption( hasAdminApproval, hasLoginApprovingDevice, @@ -152,5 +184,54 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder isTdeOffboarding, encryptedPrivateKey, encryptedUserKey); + return; + + async Task EvaluateHasManageResetPasswordPermission() + { + // PM-23174 + // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP + if (organizationUser == null) + { + return false; + } + + var organizationUserHasResetPasswordPermission = + // The repository will pull users in all statuses, so we also need to ensure that revoked-status users do not have + // permissions sent down. + organizationUser.Status is OrganizationUserStatusType.Invited or OrganizationUserStatusType.Accepted or + OrganizationUserStatusType.Confirmed && + // Admins and owners get ManageResetPassword functionally "for free" through their role. + (organizationUser.Type is OrganizationUserType.Admin or OrganizationUserType.Owner || + // Custom users can have the ManagePasswordReset permission assigned directly. + organizationUser.GetPermissions() is { ManageResetPassword: true }); + + return organizationUserHasResetPasswordPermission || + // A provider user for the given organization gets ManageResetPassword through that relationship. + await _currentContext.ProviderUserForOrgAsync(_ssoConfig.OrganizationId); + } + } + + private void BuildMasterPasswordUnlock() + { + if (_user.HasMasterPassword()) + { + _options.HasMasterPassword = true; + _options.MasterPasswordUnlock = new MasterPasswordUnlockResponseModel + { + Kdf = new MasterPasswordUnlockKdfResponseModel + { + KdfType = _user.Kdf, + Iterations = _user.KdfIterations, + Memory = _user.KdfMemory, + Parallelism = _user.KdfParallelism + }, + MasterKeyEncryptedUserKey = _user.Key!, + Salt = _user.Email.ToLowerInvariant() + }; + } + else + { + _options.HasMasterPassword = false; + } } } diff --git a/src/Identity/Models/RedirectViewModel.cs b/src/Identity/Models/RedirectViewModel.cs index 5cf7663b4b..99bc67d26b 100644 --- a/src/Identity/Models/RedirectViewModel.cs +++ b/src/Identity/Models/RedirectViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Identity.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Identity.Models; public class RedirectViewModel { diff --git a/src/Identity/Models/Request/Accounts/PreloginRequestModel.cs b/src/Identity/Models/Request/Accounts/PreloginRequestModel.cs index daae846123..a7dba7ce1d 100644 --- a/src/Identity/Models/Request/Accounts/PreloginRequestModel.cs +++ b/src/Identity/Models/Request/Accounts/PreloginRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Identity.Models.Request.Accounts; diff --git a/src/Identity/Models/Request/Accounts/RegisterRequestModel.cs b/src/Identity/Models/Request/Accounts/RegisterRequestModel.cs index 703cb1f350..44f44977dd 100644 --- a/src/Identity/Models/Request/Accounts/RegisterRequestModel.cs +++ b/src/Identity/Models/Request/Accounts/RegisterRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json; using Bit.Core; using Bit.Core.Auth.Models.Api.Request.Accounts; diff --git a/src/Identity/Program.cs b/src/Identity/Program.cs index 31a69975ad..cb6e7daf39 100644 --- a/src/Identity/Program.cs +++ b/src/Identity/Program.cs @@ -1,4 +1,7 @@ -using AspNetCoreRateLimit; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AspNetCoreRateLimit; using Bit.Core.Utilities; namespace Bit.Identity; diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index baaf9385af..8da31d87d6 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -64,10 +64,11 @@ public class Startup config.Filters.Add(new ModelStateValidationFilterAttribute()); }); - services.AddSwaggerGen(c => + services.AddSwaggerGen(config => { - c.SchemaFilter(); - c.SwaggerDoc("v1", new OpenApiInfo { Title = "Bitwarden Identity", Version = "v1" }); + config.InitializeSwaggerFilters(Environment); + + config.SwaggerDoc("v1", new OpenApiInfo { Title = "Bitwarden Identity", Version = "v1" }); }); if (!globalSettings.SelfHosted) diff --git a/src/Identity/Utilities/LoginApprovingClientTypes.cs b/src/Identity/Utilities/LoginApprovingClientTypes.cs index f0c7b831b7..28049ed16b 100644 --- a/src/Identity/Utilities/LoginApprovingClientTypes.cs +++ b/src/Identity/Utilities/LoginApprovingClientTypes.cs @@ -1,6 +1,4 @@ -using Bit.Core; -using Bit.Core.Enums; -using Bit.Core.Services; +using Bit.Core.Enums; namespace Bit.Identity.Utilities; @@ -11,28 +9,15 @@ public interface ILoginApprovingClientTypes public class LoginApprovingClientTypes : ILoginApprovingClientTypes { - public LoginApprovingClientTypes( - IFeatureService featureService) + public LoginApprovingClientTypes() { - if (featureService.IsEnabled(FeatureFlagKeys.BrowserExtensionLoginApproval)) + TypesThatCanApprove = new List { - TypesThatCanApprove = new List - { - ClientType.Desktop, - ClientType.Mobile, - ClientType.Web, - ClientType.Browser, - }; - } - else - { - TypesThatCanApprove = new List - { - ClientType.Desktop, - ClientType.Mobile, - ClientType.Web, - }; - } + ClientType.Desktop, + ClientType.Mobile, + ClientType.Web, + ClientType.Browser, + }; } public IReadOnlyCollection TypesThatCanApprove { get; } diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 1476a5ec76..e9056d030e 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -1,10 +1,12 @@ -using Bit.Core.Auth.Repositories; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Auth.Repositories; using Bit.Core.Settings; +using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; using Bit.Identity.IdentityServer; using Bit.Identity.IdentityServer.ClientProviders; using Bit.Identity.IdentityServer.RequestValidators; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.SharedWeb.Utilities; using Duende.IdentityServer.ResponseHandling; using Duende.IdentityServer.Services; @@ -25,6 +27,9 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient, SendPasswordRequestValidator>(); + services.AddTransient, SendEmailOtpRequestValidator>(); + services.AddTransient, SendNeverAuthenticateRequestValidator>(); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var identityServerBuilder = services @@ -55,7 +60,8 @@ public static class ServiceCollectionExtensions .AddResourceOwnerValidator() .AddClientStore() .AddIdentityServerCertificate(env, globalSettings) - .AddExtensionGrantValidator(); + .AddExtensionGrantValidator() + .AddExtensionGrantValidator(); if (!globalSettings.SelfHosted) { diff --git a/src/Identity/entrypoint.sh b/src/Identity/entrypoint.sh index f5f84cc220..21f8556930 100644 --- a/src/Identity/entrypoint.sh +++ b/src/Identity/entrypoint.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # Setup @@ -37,7 +37,7 @@ then mkdir -p /etc/bitwarden/ca-certificates chown -R $USERNAME:$GROUPNAME /etc/bitwarden - if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then + if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos fi @@ -46,13 +46,13 @@ else gosu_cmd="" fi -if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then +if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -if [[ $globalSettings__selfHosted == "true" ]]; then - if [[ -z $globalSettings__identityServer__certificateLocation ]]; then +if [ "$globalSettings__selfHosted" = "true" ]; then + if [ -z "$globalSettings__identityServer__certificateLocation" ]; then export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx fi fi diff --git a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs new file mode 100644 index 0000000000..5a743ba028 --- /dev/null +++ b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs @@ -0,0 +1,344 @@ +using System.Data; +using Bit.Core.Entities; +using Bit.Core.Vault.Entities; +using Bit.Infrastructure.Dapper.Utilities; +using Microsoft.Data.SqlClient; + +namespace Bit.Infrastructure.Dapper.AdminConsole.Helpers; + +public static class BulkResourceCreationService +{ + private const string _defaultErrorMessage = "Must have at least one record for bulk creation."; + public static async Task CreateCollectionsUsersAsync(SqlConnection connection, SqlTransaction transaction, + IEnumerable collectionUsers, string errorMessage = _defaultErrorMessage) + { + // Offload some work from SQL Server by pre-sorting before insert. + // This lets us use the SqlBulkCopy.ColumnOrderHints to improve performance and reduce deadlocks. + var sortedCollectionUsers = collectionUsers + .OrderBySqlGuid(cu => cu.CollectionId) + .ThenBySqlGuid(cu => cu.OrganizationUserId) + .ToList(); + + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "[dbo].[CollectionUser]"; + bulkCopy.BatchSize = 500; + bulkCopy.BulkCopyTimeout = 120; + bulkCopy.EnableStreaming = true; + bulkCopy.ColumnOrderHints.Add("CollectionId", SortOrder.Ascending); + bulkCopy.ColumnOrderHints.Add("OrganizationUserId", SortOrder.Ascending); + + var dataTable = BuildCollectionsUsersTable(bulkCopy, sortedCollectionUsers, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + + public static async Task CreateCiphersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable ciphers, string errorMessage = _defaultErrorMessage) + { + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "[dbo].[Cipher]"; + var dataTable = BuildCiphersTable(bulkCopy, ciphers, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + + public static async Task CreateFoldersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable folders, string errorMessage = _defaultErrorMessage) + { + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "[dbo].[Folder]"; + var dataTable = BuildFoldersTable(bulkCopy, folders, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + + public static async Task CreateCollectionCiphersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable collectionCiphers, string errorMessage = _defaultErrorMessage) + { + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "[dbo].[CollectionCipher]"; + var dataTable = BuildCollectionCiphersTable(bulkCopy, collectionCiphers, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + + public static async Task CreateTempCiphersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable ciphers, string errorMessage = _defaultErrorMessage) + { + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "#TempCipher"; + var dataTable = BuildCiphersTable(bulkCopy, ciphers, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + + private static DataTable BuildCollectionsUsersTable(SqlBulkCopy bulkCopy, IEnumerable collectionUsers, string errorMessage) + { + var collectionUser = collectionUsers.FirstOrDefault(); + + if (collectionUser == null) + { + throw new ApplicationException(errorMessage); + } + + var table = new DataTable("CollectionUserDataTable"); + + var collectionIdColumn = new DataColumn(nameof(collectionUser.CollectionId), collectionUser.CollectionId.GetType()); + table.Columns.Add(collectionIdColumn); + var orgUserIdColumn = new DataColumn(nameof(collectionUser.OrganizationUserId), collectionUser.OrganizationUserId.GetType()); + table.Columns.Add(orgUserIdColumn); + var readOnlyColumn = new DataColumn(nameof(collectionUser.ReadOnly), collectionUser.ReadOnly.GetType()); + table.Columns.Add(readOnlyColumn); + var hidePasswordsColumn = new DataColumn(nameof(collectionUser.HidePasswords), collectionUser.HidePasswords.GetType()); + table.Columns.Add(hidePasswordsColumn); + var manageColumn = new DataColumn(nameof(collectionUser.Manage), collectionUser.Manage.GetType()); + table.Columns.Add(manageColumn); + + foreach (DataColumn col in table.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[2]; + keys[0] = collectionIdColumn; + keys[1] = orgUserIdColumn; + table.PrimaryKey = keys; + + foreach (var collectionUserRecord in collectionUsers) + { + var row = table.NewRow(); + + row[collectionIdColumn] = collectionUserRecord.CollectionId; + row[orgUserIdColumn] = collectionUserRecord.OrganizationUserId; + row[readOnlyColumn] = collectionUserRecord.ReadOnly; + row[hidePasswordsColumn] = collectionUserRecord.HidePasswords; + row[manageColumn] = collectionUserRecord.Manage; + + table.Rows.Add(row); + } + + return table; + } + + public static async Task CreateCollectionsAsync(SqlConnection connection, SqlTransaction transaction, + IEnumerable collections, string errorMessage = _defaultErrorMessage) + { + // Offload some work from SQL Server by pre-sorting before insert. + // This lets us use the SqlBulkCopy.ColumnOrderHints to improve performance and reduce deadlocks. + var sortedCollections = collections.OrderBySqlGuid(c => c.Id).ToList(); + + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "[dbo].[Collection]"; + bulkCopy.BatchSize = 500; + bulkCopy.BulkCopyTimeout = 120; + bulkCopy.EnableStreaming = true; + bulkCopy.ColumnOrderHints.Add("Id", SortOrder.Ascending); + + var dataTable = BuildCollectionsTable(bulkCopy, sortedCollections, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + + private static DataTable BuildCollectionsTable(SqlBulkCopy bulkCopy, IEnumerable collections, string errorMessage) + { + var collection = collections.FirstOrDefault(); + + if (collection == null) + { + throw new ApplicationException(errorMessage); + } + + var collectionsTable = new DataTable("CollectionDataTable"); + + var idColumn = new DataColumn(nameof(collection.Id), collection.Id.GetType()); + collectionsTable.Columns.Add(idColumn); + var organizationIdColumn = new DataColumn(nameof(collection.OrganizationId), collection.OrganizationId.GetType()); + collectionsTable.Columns.Add(organizationIdColumn); + var nameColumn = new DataColumn(nameof(collection.Name), collection.Name.GetType()); + collectionsTable.Columns.Add(nameColumn); + var creationDateColumn = new DataColumn(nameof(collection.CreationDate), collection.CreationDate.GetType()); + collectionsTable.Columns.Add(creationDateColumn); + var revisionDateColumn = new DataColumn(nameof(collection.RevisionDate), collection.RevisionDate.GetType()); + collectionsTable.Columns.Add(revisionDateColumn); + var externalIdColumn = new DataColumn(nameof(collection.ExternalId), typeof(string)); + collectionsTable.Columns.Add(externalIdColumn); + var typeColumn = new DataColumn(nameof(collection.Type), collection.Type.GetType()); + collectionsTable.Columns.Add(typeColumn); + var defaultUserCollectionEmailColumn = new DataColumn(nameof(collection.DefaultUserCollectionEmail), typeof(string)); + collectionsTable.Columns.Add(defaultUserCollectionEmailColumn); + + foreach (DataColumn col in collectionsTable.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[1]; + keys[0] = idColumn; + collectionsTable.PrimaryKey = keys; + + foreach (var collectionRecord in collections) + { + var row = collectionsTable.NewRow(); + + row[idColumn] = collectionRecord.Id; + row[organizationIdColumn] = collectionRecord.OrganizationId; + row[nameColumn] = collectionRecord.Name; + row[creationDateColumn] = collectionRecord.CreationDate; + row[revisionDateColumn] = collectionRecord.RevisionDate; + row[externalIdColumn] = collectionRecord.ExternalId; + row[typeColumn] = collectionRecord.Type; + row[defaultUserCollectionEmailColumn] = collectionRecord.DefaultUserCollectionEmail; + + collectionsTable.Rows.Add(row); + } + + return collectionsTable; + } + + private static DataTable BuildCiphersTable(SqlBulkCopy bulkCopy, IEnumerable ciphers, string errorMessage) + { + var c = ciphers.FirstOrDefault(); + + if (c == null) + { + throw new ApplicationException(errorMessage); + } + + var ciphersTable = new DataTable("CipherDataTable"); + + var idColumn = new DataColumn(nameof(c.Id), c.Id.GetType()); + ciphersTable.Columns.Add(idColumn); + var userIdColumn = new DataColumn(nameof(c.UserId), typeof(Guid)); + ciphersTable.Columns.Add(userIdColumn); + var organizationId = new DataColumn(nameof(c.OrganizationId), typeof(Guid)); + ciphersTable.Columns.Add(organizationId); + var typeColumn = new DataColumn(nameof(c.Type), typeof(short)); + ciphersTable.Columns.Add(typeColumn); + var dataColumn = new DataColumn(nameof(c.Data), typeof(string)); + ciphersTable.Columns.Add(dataColumn); + var favoritesColumn = new DataColumn(nameof(c.Favorites), typeof(string)); + ciphersTable.Columns.Add(favoritesColumn); + var foldersColumn = new DataColumn(nameof(c.Folders), typeof(string)); + ciphersTable.Columns.Add(foldersColumn); + var attachmentsColumn = new DataColumn(nameof(c.Attachments), typeof(string)); + ciphersTable.Columns.Add(attachmentsColumn); + var creationDateColumn = new DataColumn(nameof(c.CreationDate), c.CreationDate.GetType()); + ciphersTable.Columns.Add(creationDateColumn); + var revisionDateColumn = new DataColumn(nameof(c.RevisionDate), c.RevisionDate.GetType()); + ciphersTable.Columns.Add(revisionDateColumn); + var deletedDateColumn = new DataColumn(nameof(c.DeletedDate), typeof(DateTime)); + ciphersTable.Columns.Add(deletedDateColumn); + var repromptColumn = new DataColumn(nameof(c.Reprompt), typeof(short)); + ciphersTable.Columns.Add(repromptColumn); + var keyColummn = new DataColumn(nameof(c.Key), typeof(string)); + ciphersTable.Columns.Add(keyColummn); + + foreach (DataColumn col in ciphersTable.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[1]; + keys[0] = idColumn; + ciphersTable.PrimaryKey = keys; + + foreach (var cipher in ciphers) + { + var row = ciphersTable.NewRow(); + + row[idColumn] = cipher.Id; + row[userIdColumn] = cipher.UserId.HasValue ? (object)cipher.UserId.Value : DBNull.Value; + row[organizationId] = cipher.OrganizationId.HasValue ? (object)cipher.OrganizationId.Value : DBNull.Value; + row[typeColumn] = (short)cipher.Type; + row[dataColumn] = cipher.Data; + row[favoritesColumn] = cipher.Favorites; + row[foldersColumn] = cipher.Folders; + row[attachmentsColumn] = cipher.Attachments; + row[creationDateColumn] = cipher.CreationDate; + row[revisionDateColumn] = cipher.RevisionDate; + row[deletedDateColumn] = cipher.DeletedDate.HasValue ? (object)cipher.DeletedDate : DBNull.Value; + row[repromptColumn] = cipher.Reprompt.HasValue ? cipher.Reprompt.Value : DBNull.Value; + row[keyColummn] = cipher.Key; + + ciphersTable.Rows.Add(row); + } + + return ciphersTable; + } + + private static DataTable BuildFoldersTable(SqlBulkCopy bulkCopy, IEnumerable folders, string errorMessage) + { + var f = folders.FirstOrDefault(); + + if (f == null) + { + throw new ApplicationException(errorMessage); + } + + var foldersTable = new DataTable("FolderDataTable"); + + var idColumn = new DataColumn(nameof(f.Id), f.Id.GetType()); + foldersTable.Columns.Add(idColumn); + var userIdColumn = new DataColumn(nameof(f.UserId), f.UserId.GetType()); + foldersTable.Columns.Add(userIdColumn); + var nameColumn = new DataColumn(nameof(f.Name), typeof(string)); + foldersTable.Columns.Add(nameColumn); + var creationDateColumn = new DataColumn(nameof(f.CreationDate), f.CreationDate.GetType()); + foldersTable.Columns.Add(creationDateColumn); + var revisionDateColumn = new DataColumn(nameof(f.RevisionDate), f.RevisionDate.GetType()); + foldersTable.Columns.Add(revisionDateColumn); + + foreach (DataColumn col in foldersTable.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[1]; + keys[0] = idColumn; + foldersTable.PrimaryKey = keys; + + foreach (var folder in folders) + { + var row = foldersTable.NewRow(); + + row[idColumn] = folder.Id; + row[userIdColumn] = folder.UserId; + row[nameColumn] = folder.Name; + row[creationDateColumn] = folder.CreationDate; + row[revisionDateColumn] = folder.RevisionDate; + + foldersTable.Rows.Add(row); + } + + return foldersTable; + } + + private static DataTable BuildCollectionCiphersTable(SqlBulkCopy bulkCopy, IEnumerable collectionCiphers, string errorMessage) + { + var cc = collectionCiphers.FirstOrDefault(); + + if (cc == null) + { + throw new ApplicationException(errorMessage); + } + + var collectionCiphersTable = new DataTable("CollectionCipherDataTable"); + + var collectionIdColumn = new DataColumn(nameof(cc.CollectionId), cc.CollectionId.GetType()); + collectionCiphersTable.Columns.Add(collectionIdColumn); + var cipherIdColumn = new DataColumn(nameof(cc.CipherId), cc.CipherId.GetType()); + collectionCiphersTable.Columns.Add(cipherIdColumn); + + foreach (DataColumn col in collectionCiphersTable.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[2]; + keys[0] = collectionIdColumn; + keys[1] = cipherIdColumn; + collectionCiphersTable.PrimaryKey = keys; + + foreach (var collectionCipher in collectionCiphers) + { + var row = collectionCiphersTable.NewRow(); + + row[collectionIdColumn] = collectionCipher.CollectionId; + row[cipherIdColumn] = collectionCipher.CipherId; + + collectionCiphersTable.Rows.Add(row); + } + + return collectionCiphersTable; + } +} diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/EventRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/EventRepository.cs index 85e3cc7fc2..2ddc5679d5 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/EventRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/EventRepository.cs @@ -2,6 +2,7 @@ using Bit.Core.Entities; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Entities; using Bit.Core.Settings; using Bit.Core.Vault.Entities; using Dapper; @@ -41,8 +42,30 @@ public class EventRepository : Repository, IEventRepository }, startDate, endDate, pageOptions); } - public async Task> GetManyByOrganizationActingUserAsync(Guid organizationId, Guid actingUserId, + public async Task> GetManyBySecretAsync(Secret secret, DateTime startDate, DateTime endDate, PageOptions pageOptions) + { + return await GetManyAsync($"[{Schema}].[Event_ReadPageBySecretId]", + new Dictionary + { + ["@SecretId"] = secret.Id + }, startDate, endDate, pageOptions); + + } + + public async Task> GetManyByProjectAsync(Project project, + DateTime startDate, DateTime endDate, PageOptions pageOptions) + { + return await GetManyAsync($"[{Schema}].[Event_ReadPageByProjectId]", + new Dictionary + { + ["@ProjectId"] = project.Id + }, startDate, endDate, pageOptions); + + } + + public async Task> GetManyByOrganizationActingUserAsync(Guid organizationId, Guid actingUserId, + DateTime startDate, DateTime endDate, PageOptions pageOptions) { return await GetManyAsync($"[{Schema}].[Event_ReadPageByOrganizationIdActingUserId]", new Dictionary @@ -205,6 +228,10 @@ public class EventRepository : Repository, IEventRepository eventsTable.Columns.Add(secretIdColumn); var serviceAccountIdColumn = new DataColumn(nameof(e.ServiceAccountId), typeof(Guid)); eventsTable.Columns.Add(serviceAccountIdColumn); + var projectIdColumn = new DataColumn(nameof(e.ProjectId), typeof(Guid)); + eventsTable.Columns.Add(projectIdColumn); + var grantedServiceAccountIdColumn = new DataColumn(nameof(e.GrantedServiceAccountId), typeof(Guid)); + eventsTable.Columns.Add(grantedServiceAccountIdColumn); foreach (DataColumn col in eventsTable.Columns) { @@ -237,7 +264,8 @@ public class EventRepository : Repository, IEventRepository row[dateColumn] = ev.Date; row[secretIdColumn] = ev.SecretId.HasValue ? ev.SecretId.Value : DBNull.Value; row[serviceAccountIdColumn] = ev.ServiceAccountId.HasValue ? ev.ServiceAccountId.Value : DBNull.Value; - + row[projectIdColumn] = ev.ProjectId.HasValue ? ev.ProjectId.Value : DBNull.Value; + row[grantedServiceAccountIdColumn] = ev.GrantedServiceAccountId.HasValue ? ev.GrantedServiceAccountId.Value : DBNull.Value; eventsTable.Rows.Add(row); } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs index f3227dfd22..005e93c6aa 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs @@ -40,4 +40,32 @@ public class OrganizationIntegrationConfigurationRepository : Repository> GetAllConfigurationDetailsAsync() + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[OrganizationIntegrationConfigurationDetails_ReadMany]", + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + + public async Task> GetManyByIntegrationAsync(Guid organizationIntegrationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[OrganizationIntegrationConfiguration_ReadManyByOrganizationIntegrationId]", + new + { + OrganizationIntegrationId = organizationIntegrationId + }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationRepository.cs index 99f0e35378..ece9697a31 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationRepository.cs @@ -1,6 +1,9 @@ -using Bit.Core.AdminConsole.Entities; +using System.Data; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Repositories; using Bit.Core.Settings; +using Dapper; +using Microsoft.Data.SqlClient; namespace Bit.Infrastructure.Dapper.Repositories; @@ -13,4 +16,17 @@ public class OrganizationIntegrationRepository : Repository> GetManyByOrganizationAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[OrganizationIntegration_ReadManyByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index 27a08df3ed..96ddc8c7da 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -220,4 +220,35 @@ public class OrganizationRepository : Repository, IOrganizat return result.SingleOrDefault() ?? new OrganizationSeatCounts(); } } + + public async Task> GetOrganizationsForSubscriptionSyncAsync() + { + await using var connection = new SqlConnection(ConnectionString); + + return await connection.QueryAsync( + "[dbo].[Organization_GetOrganizationsForSubscriptionSync]", + commandType: CommandType.StoredProcedure); + } + + public async Task UpdateSuccessfulOrganizationSyncStatusAsync(IEnumerable successfulOrganizations, DateTime syncDate) + { + await using var connection = new SqlConnection(ConnectionString); + + await connection.ExecuteAsync("[dbo].[Organization_UpdateSubscriptionStatus]", + new + { + SuccessfulOrganizations = successfulOrganizations.ToGuidIdArrayTVP(), + SyncDate = syncDate + }, + commandType: CommandType.StoredProcedure); + } + + public async Task IncrementSeatCountAsync(Guid organizationId, int increaseAmount, DateTime requestDate) + { + await using var connection = new SqlConnection(ConnectionString); + + await connection.ExecuteAsync("[dbo].[Organization_IncrementSeatCount]", + new { OrganizationId = organizationId, SeatsToAdd = increaseAmount, RequestDate = requestDate }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 5a6fcbe4aa..5f389ae56d 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Utilities.DebuggingInstruments; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; @@ -12,6 +13,7 @@ using Bit.Core.Repositories; using Bit.Core.Settings; using Dapper; using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; #nullable enable @@ -25,8 +27,9 @@ public class OrganizationUserRepository : Repository, IO /// https://github.com/dotnet/SqlClient/issues/54 /// private string _marsConnectionString; + private readonly ILogger _logger; - public OrganizationUserRepository(GlobalSettings globalSettings) + public OrganizationUserRepository(GlobalSettings globalSettings, ILogger logger) : base(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) { var builder = new SqlConnectionStringBuilder(ConnectionString) @@ -34,6 +37,7 @@ public class OrganizationUserRepository : Repository, IO MultipleActiveResultSets = true, }; _marsConnectionString = builder.ToString(); + _logger = logger; } public async Task GetCountByOrganizationIdAsync(Guid organizationId) @@ -264,6 +268,68 @@ public class OrganizationUserRepository : Repository, IO } } + public async Task> GetManyDetailsByOrganizationAsync_vNext(Guid organizationId, bool includeGroups, bool includeCollections) + { + using (var connection = new SqlConnection(ConnectionString)) + { + // Use a single call that returns multiple result sets + var results = await connection.QueryMultipleAsync( + "[dbo].[OrganizationUserUserDetails_ReadByOrganizationId_V2]", + new + { + OrganizationId = organizationId, + IncludeGroups = includeGroups, + IncludeCollections = includeCollections + }, + commandType: CommandType.StoredProcedure); + + // Read the user details (first result set) + var users = (await results.ReadAsync()).ToList(); + + // Read group associations (second result set, if requested) + Dictionary>? userGroupMap = null; + if (includeGroups) + { + var groupUsers = await results.ReadAsync(); + userGroupMap = groupUsers + .GroupBy(gu => gu.OrganizationUserId) + .ToDictionary(g => g.Key, g => g.Select(gu => gu.GroupId).ToList()); + } + + // Read collection associations (third result set, if requested) + Dictionary>? userCollectionMap = null; + if (includeCollections) + { + var collectionUsers = await results.ReadAsync(); + userCollectionMap = collectionUsers + .GroupBy(cu => cu.OrganizationUserId) + .ToDictionary(g => g.Key, g => g.Select(cu => new CollectionAccessSelection + { + Id = cu.CollectionId, + ReadOnly = cu.ReadOnly, + HidePasswords = cu.HidePasswords, + Manage = cu.Manage + }).ToList()); + } + + // Map the associations to users + foreach (var user in users) + { + if (userGroupMap != null) + { + user.Groups = userGroupMap.GetValueOrDefault(user.Id, new List()); + } + + if (userCollectionMap != null) + { + user.Collections = userCollectionMap.GetValueOrDefault(user.Id, new List()); + } + } + + return users; + } + } + public async Task> GetManyDetailsByUserAsync(Guid userId, OrganizationUserStatusType? status = null) { @@ -305,6 +371,8 @@ public class OrganizationUserRepository : Repository, IO public async Task CreateAsync(OrganizationUser obj, IEnumerable collections) { + _logger.LogUserInviteStateDiagnostics(obj); + obj.SetNewId(); var objWithCollections = JsonSerializer.Deserialize( JsonSerializer.Serialize(obj))!; @@ -323,6 +391,8 @@ public class OrganizationUserRepository : Repository, IO public async Task ReplaceAsync(OrganizationUser obj, IEnumerable collections) { + _logger.LogUserInviteStateDiagnostics(obj); + var objWithCollections = JsonSerializer.Deserialize( JsonSerializer.Serialize(obj))!; objWithCollections.Collections = collections.ToArrayTVP(); @@ -406,6 +476,8 @@ public class OrganizationUserRepository : Repository, IO public async Task?> CreateManyAsync(IEnumerable organizationUsers) { + _logger.LogUserInviteStateDiagnostics(organizationUsers); + organizationUsers = organizationUsers.ToList(); if (!organizationUsers.Any()) { @@ -430,6 +502,8 @@ public class OrganizationUserRepository : Repository, IO public async Task ReplaceManyAsync(IEnumerable organizationUsers) { + _logger.LogUserInviteStateDiagnostics(organizationUsers); + organizationUsers = organizationUsers.ToList(); if (!organizationUsers.Any()) { @@ -538,7 +612,7 @@ public class OrganizationUserRepository : Repository, IO using (var connection = new SqlConnection(ConnectionString)) { var results = await connection.QueryAsync( - $"[{Schema}].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains]", + $"[{Schema}].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2]", new { OrganizationId = organizationId }, commandType: CommandType.StoredProcedure); @@ -573,12 +647,14 @@ public class OrganizationUserRepository : Repository, IO { await using var connection = new SqlConnection(_marsConnectionString); + var organizationUsersList = organizationUserCollection.ToList(); + await connection.ExecuteAsync( $"[{Schema}].[OrganizationUser_CreateManyWithCollectionsAndGroups]", new { - OrganizationUserData = JsonSerializer.Serialize(organizationUserCollection.Select(x => x.OrganizationUser)), - CollectionData = JsonSerializer.Serialize(organizationUserCollection + OrganizationUserData = JsonSerializer.Serialize(organizationUsersList.Select(x => x.OrganizationUser)), + CollectionData = JsonSerializer.Serialize(organizationUsersList .SelectMany(x => x.Collections, (user, collection) => new CollectionUser { CollectionId = collection.Id, @@ -587,7 +663,7 @@ public class OrganizationUserRepository : Repository, IO HidePasswords = collection.HidePasswords, Manage = collection.Manage })), - GroupData = JsonSerializer.Serialize(organizationUserCollection + GroupData = JsonSerializer.Serialize(organizationUsersList .SelectMany(x => x.Groups, (user, group) => new GroupUser { GroupId = group, diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs index 071ff3153a..83d5ef6a70 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs @@ -73,4 +73,32 @@ public class PolicyRepository : Repository, IPolicyRepository return results.ToList(); } } + + public async Task> GetPolicyDetailsByUserIdsAndPolicyType(IEnumerable userIds, PolicyType type) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[PolicyDetails_ReadByUserIdsPolicyType]", + new + { + UserIds = userIds.ToGuidIdArrayTVP(), + PolicyType = (byte)type + }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + + public async Task> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[PolicyDetails_ReadByOrganizationId]", + new { @OrganizationId = organizationId, @PolicyType = policyType }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } } diff --git a/src/Infrastructure.Dapper/Auth/Helpers/EmergencyAccessHelpers.cs b/src/Infrastructure.Dapper/Auth/Helpers/EmergencyAccessHelpers.cs index 34e4fcda69..6d35c78a8f 100644 --- a/src/Infrastructure.Dapper/Auth/Helpers/EmergencyAccessHelpers.cs +++ b/src/Infrastructure.Dapper/Auth/Helpers/EmergencyAccessHelpers.cs @@ -1,4 +1,7 @@ -using System.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; using Bit.Core.Auth.Entities; namespace Bit.Infrastructure.Dapper.Auth.Helpers; diff --git a/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs b/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs index db6419d389..c9cf796986 100644 --- a/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs +++ b/src/Infrastructure.Dapper/Auth/Repositories/AuthRequestRepository.cs @@ -14,13 +14,12 @@ namespace Bit.Infrastructure.Dapper.Auth.Repositories; public class AuthRequestRepository : Repository, IAuthRequestRepository { + private readonly GlobalSettings _globalSettings; public AuthRequestRepository(GlobalSettings globalSettings) - : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) - { } - - public AuthRequestRepository(string connectionString, string readOnlyConnectionString) - : base(connectionString, readOnlyConnectionString) - { } + : base(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { + _globalSettings = globalSettings; + } public async Task DeleteExpiredAsync( TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration) @@ -52,6 +51,18 @@ public class AuthRequestRepository : Repository, IAuthRequest } } + public async Task> GetManyPendingAuthRequestByUserId(Guid userId) + { + var expirationMinutes = (int)_globalSettings.PasswordlessAuth.UserRequestExpiration.TotalMinutes; + using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[AuthRequest_ReadPendingByUserId]", + new { UserId = userId, ExpirationMinutes = expirationMinutes }, + commandType: CommandType.StoredProcedure); + + return results; + } + public async Task> GetManyPendingByOrganizationIdAsync(Guid organizationId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.Dapper/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs b/src/Infrastructure.Dapper/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs index e43eb9a71f..500b76a309 100644 --- a/src/Infrastructure.Dapper/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs +++ b/src/Infrastructure.Dapper/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs @@ -1,4 +1,7 @@ -using System.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Settings; diff --git a/src/Infrastructure.Dapper/Billing/Repositories/OrganizationInstallationRepository.cs b/src/Infrastructure.Dapper/Billing/Repositories/OrganizationInstallationRepository.cs index f73eefb793..89c24ae6c6 100644 --- a/src/Infrastructure.Dapper/Billing/Repositories/OrganizationInstallationRepository.cs +++ b/src/Infrastructure.Dapper/Billing/Repositories/OrganizationInstallationRepository.cs @@ -1,6 +1,9 @@ -using System.Data; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; +using Bit.Core.Billing.Organizations.Entities; +using Bit.Core.Billing.Organizations.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; using Dapper; diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index 00728ec1a0..35fc094973 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Repositories; +using Bit.Core.Billing.Organizations.Repositories; using Bit.Core.Billing.Providers.Repositories; -using Bit.Core.Billing.Repositories; using Bit.Core.Dirt.Reports.Repositories; using Bit.Core.Dirt.Repositories; using Bit.Core.KeyManagement.Repositories; diff --git a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs index 7a5fe1c8c2..3d001cce92 100644 --- a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs +++ b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs @@ -1,5 +1,9 @@ -using System.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; @@ -20,26 +24,153 @@ public class OrganizationReportRepository : Repository { } - public async Task> GetByOrganizationIdAsync(Guid organizationId) + public async Task GetLatestByOrganizationIdAsync(Guid organizationId) { using (var connection = new SqlConnection(ReadOnlyConnectionString)) { - var results = await connection.QueryAsync( - $"[{Schema}].[OrganizationReport_ReadByOrganizationId]", + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_GetLatestByOrganizationId]", new { OrganizationId = organizationId }, commandType: CommandType.StoredProcedure); - return results.ToList(); + return result; } } - public async Task GetLatestByOrganizationIdAsync(Guid organizationId) + public async Task UpdateSummaryDataAsync(Guid organizationId, Guid reportId, string summaryData) { - return await GetByOrganizationIdAsync(organizationId) - .ContinueWith(task => + using (var connection = new SqlConnection(ConnectionString)) { - var reports = task.Result; - return reports.OrderByDescending(r => r.CreationDate).FirstOrDefault(); - }); + var parameters = new + { + Id = reportId, + OrganizationId = organizationId, + SummaryData = summaryData, + RevisionDate = DateTime.UtcNow + }; + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationReport_UpdateSummaryData]", + parameters, + commandType: CommandType.StoredProcedure); + + // Return the updated report + return await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_ReadById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + } + } + + public async Task GetSummaryDataAsync(Guid reportId) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_GetSummaryDataById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + + public async Task> GetSummaryDataByDateRangeAsync( + Guid organizationId, + DateTime startDate, DateTime + endDate) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var parameters = new + { + OrganizationId = organizationId, + StartDate = startDate, + EndDate = endDate + }; + + var results = await connection.QueryAsync( + $"[{Schema}].[OrganizationReport_GetSummariesByDateRange]", + parameters, + commandType: CommandType.StoredProcedure); + + return results; + } + } + + public async Task GetReportDataAsync(Guid reportId) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_GetReportDataById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + + public async Task UpdateReportDataAsync(Guid organizationId, Guid reportId, string reportData) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var parameters = new + { + OrganizationId = organizationId, + Id = reportId, + ReportData = reportData, + RevisionDate = DateTime.UtcNow + }; + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationReport_UpdateReportData]", + parameters, + commandType: CommandType.StoredProcedure); + + // Return the updated report + return await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_ReadById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + } + } + + public async Task GetApplicationDataAsync(Guid reportId) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_GetApplicationDataById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + + public async Task UpdateApplicationDataAsync(Guid organizationId, Guid reportId, string applicationData) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var parameters = new + { + OrganizationId = organizationId, + Id = reportId, + ApplicationData = applicationData, + RevisionDate = DateTime.UtcNow + }; + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationReport_UpdateApplicationData]", + parameters, + commandType: CommandType.StoredProcedure); + + // Return the updated report + return await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_ReadById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + } } } diff --git a/src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs b/src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs index b6843d9801..63b1c21f49 100644 --- a/src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs +++ b/src/Infrastructure.Dapper/NotificationCenter/Repositories/NotificationRepository.cs @@ -56,4 +56,21 @@ public class NotificationRepository : Repository, INotificat ContinuationToken = data.Count < pageOptions.PageSize ? null : (pageNumber + 1).ToString() }; } + + public async Task> MarkNotificationsAsDeletedByTask(Guid taskId) + { + await using var connection = new SqlConnection(ConnectionString); + + var results = await connection.QueryAsync( + "[dbo].[Notification_MarkAsDeletedByTask]", + new + { + TaskId = taskId, + }, + commandType: CommandType.StoredProcedure); + + var data = results.ToList(); + + return data; + } } diff --git a/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs index 5ed82a9a2c..64b1a74072 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs @@ -45,6 +45,19 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos } } + public async Task> GetManySharedByOrganizationIdAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[CollectionCipher_ReadSharedByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + public async Task> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index 2e2c90d399..c2a59f75aa 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -2,9 +2,12 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Settings; +using Bit.Core.Utilities; +using Bit.Infrastructure.Dapper.AdminConsole.Helpers; using Dapper; using Microsoft.Data.SqlClient; @@ -79,6 +82,19 @@ public class CollectionRepository : Repository, ICollectionRep } } + public async Task> GetManySharedCollectionsByOrganizationIdAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadSharedCollectionsByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + public async Task>> GetManyByOrganizationIdWithAccessAsync(Guid organizationId) { using (var connection = new SqlConnection(ConnectionString)) @@ -209,6 +225,8 @@ public class CollectionRepository : Repository, ICollectionRep public async Task CreateAsync(Collection obj, IEnumerable? groups, IEnumerable? users) { obj.SetNewId(); + + var objWithGroupsAndUsers = JsonSerializer.Deserialize(JsonSerializer.Serialize(obj))!; objWithGroupsAndUsers.Groups = groups != null ? groups.ToArrayTVP() : Enumerable.Empty().ToArrayTVP(); @@ -309,6 +327,101 @@ public class CollectionRepository : Repository, ICollectionRep } } + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) + { + organizationUserIds = organizationUserIds.ToList(); + if (!organizationUserIds.Any()) + { + return; + } + + await using var connection = new SqlConnection(ConnectionString); + connection.Open(); + await using var transaction = connection.BeginTransaction(); + try + { + var orgUserIdWithDefaultCollection = await GetOrgUserIdsWithDefaultCollectionAsync(connection, transaction, organizationId); + + var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection); + + var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName); + + if (!collectionUsers.Any() || !collections.Any()) + { + return; + } + + await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections); + await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + + private async Task> GetOrgUserIdsWithDefaultCollectionAsync(SqlConnection connection, SqlTransaction transaction, Guid organizationId) + { + const string sql = @" + SELECT + ou.Id AS OrganizationUserId + FROM + OrganizationUser ou + INNER JOIN + CollectionUser cu ON cu.OrganizationUserId = ou.Id + INNER JOIN + Collection c ON c.Id = cu.CollectionId + WHERE + ou.OrganizationId = @OrganizationId + AND c.Type = @CollectionType; + "; + + var organizationUserIds = await connection.QueryAsync( + sql, + new { OrganizationId = organizationId, CollectionType = CollectionType.DefaultUserCollection }, + transaction: transaction + ); + + return organizationUserIds.ToHashSet(); + } + + private (List collectionUser, List collection) BuildDefaultCollectionForUsers(Guid organizationId, IEnumerable missingDefaultCollectionUserIds, string defaultCollectionName) + { + var collectionUsers = new List(); + var collections = new List(); + + foreach (var orgUserId in missingDefaultCollectionUserIds) + { + var collectionId = CoreHelpers.GenerateComb(); + + collections.Add(new Collection + { + Id = collectionId, + OrganizationId = organizationId, + Name = defaultCollectionName, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + Type = CollectionType.DefaultUserCollection, + DefaultUserCollectionEmail = null + + }); + + collectionUsers.Add(new CollectionUser + { + CollectionId = collectionId, + OrganizationUserId = orgUserId, + ReadOnly = false, + HidePasswords = false, + Manage = true, + }); + } + + return (collectionUsers, collections); + } + public class CollectionWithGroupsAndUsers : Collection { [DisallowNull] diff --git a/src/Infrastructure.Dapper/SecretsManager/Repositories/ApiKeyRepository.cs b/src/Infrastructure.Dapper/SecretsManager/Repositories/ApiKeyRepository.cs index c362ae2369..52309344f7 100644 --- a/src/Infrastructure.Dapper/SecretsManager/Repositories/ApiKeyRepository.cs +++ b/src/Infrastructure.Dapper/SecretsManager/Repositories/ApiKeyRepository.cs @@ -1,4 +1,7 @@ -using System.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; using Bit.Core.SecretsManager.Repositories; diff --git a/src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs b/src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs new file mode 100644 index 0000000000..fc548e2ff0 --- /dev/null +++ b/src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs @@ -0,0 +1,26 @@ +using System.Data.SqlTypes; + +namespace Bit.Infrastructure.Dapper.Utilities; + +public static class SqlGuidHelpers +{ + /// + /// Sorts the source IEnumerable by the specified Guid property using the comparison logic. + /// This is required because MSSQL server compares (and therefore sorts) Guids differently to C#. + /// Ref: https://learn.microsoft.com/en-us/sql/connect/ado-net/sql/compare-guid-uniqueidentifier-values + /// + public static IOrderedEnumerable OrderBySqlGuid( + this IEnumerable source, + Func keySelector) + { + return source.OrderBy(x => new SqlGuid(keySelector(x))); + } + + /// + public static IOrderedEnumerable ThenBySqlGuid( + this IOrderedEnumerable source, + Func keySelector) + { + return source.ThenBy(x => new SqlGuid(keySelector(x))); + } +} diff --git a/src/Infrastructure.Dapper/Vault/Helpers/CipherHelpers.cs b/src/Infrastructure.Dapper/Vault/Helpers/CipherHelpers.cs index 8f60d81d90..0ac780ce08 100644 --- a/src/Infrastructure.Dapper/Vault/Helpers/CipherHelpers.cs +++ b/src/Infrastructure.Dapper/Vault/Helpers/CipherHelpers.cs @@ -1,4 +1,7 @@ -using System.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; using Bit.Core.Vault.Entities; using Dapper; diff --git a/src/Infrastructure.Dapper/Vault/Helpers/FolderHelpers.cs b/src/Infrastructure.Dapper/Vault/Helpers/FolderHelpers.cs index 4428316de2..8fc2bec702 100644 --- a/src/Infrastructure.Dapper/Vault/Helpers/FolderHelpers.cs +++ b/src/Infrastructure.Dapper/Vault/Helpers/FolderHelpers.cs @@ -1,4 +1,7 @@ -using System.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; using Bit.Core.Vault.Entities; using Dapper; diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index 9c75295f3f..48232ef484 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -1,14 +1,18 @@ -using System.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; using System.Text.Json; using Bit.Core.Entities; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Settings; using Bit.Core.Tools.Entities; +using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Infrastructure.Dapper.AdminConsole.Helpers; using Bit.Infrastructure.Dapper.Repositories; -using Bit.Infrastructure.Dapper.Vault.Helpers; using Dapper; using Microsoft.Data.SqlClient; @@ -235,11 +239,24 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task ArchiveAsync(IEnumerable ids, Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteScalarAsync( + $"[{Schema}].[Cipher_Archive]", + new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results; + } + } + public async Task DeleteAttachmentAsync(Guid cipherId, string attachmentId) { using (var connection = new SqlConnection(ConnectionString)) { - var results = await connection.ExecuteAsync( + await connection.ExecuteAsync( $"[{Schema}].[Cipher_DeleteAttachment]", new { Id = cipherId, AttachmentId = attachmentId }, commandType: CommandType.StoredProcedure); @@ -366,18 +383,7 @@ public class CipherRepository : Repository, ICipherRepository } // Bulk copy data into temp table - using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) - { - bulkCopy.DestinationTableName = "#TempCipher"; - var ciphersTable = ciphers.ToDataTable(); - foreach (DataColumn col in ciphersTable.Columns) - { - bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); - } - - ciphersTable.PrimaryKey = new DataColumn[] { ciphersTable.Columns[0] }; - await bulkCopy.WriteToServerAsync(ciphersTable); - } + await BulkResourceCreationService.CreateTempCiphersAsync(connection, transaction, ciphers); // Update cipher table from temp table var sql = @" @@ -433,12 +439,7 @@ public class CipherRepository : Repository, ICipherRepository } // 2. Bulk copy into temp tables. - using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) - { - bulkCopy.DestinationTableName = "#TempCipher"; - var dataTable = BuildCiphersTable(bulkCopy, ciphers); - bulkCopy.WriteToServer(dataTable); - } + await BulkResourceCreationService.CreateTempCiphersAsync(connection, transaction, ciphers); // 3. Insert into real tables from temp tables and clean up. @@ -504,20 +505,10 @@ public class CipherRepository : Repository, ICipherRepository { if (folders.Any()) { - using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) - { - bulkCopy.DestinationTableName = "[dbo].[Folder]"; - var dataTable = BuildFoldersTable(bulkCopy, folders); - bulkCopy.WriteToServer(dataTable); - } + await BulkResourceCreationService.CreateFoldersAsync(connection, transaction, folders); } - using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) - { - bulkCopy.DestinationTableName = "[dbo].[Cipher]"; - var dataTable = BuildCiphersTable(bulkCopy, ciphers); - bulkCopy.WriteToServer(dataTable); - } + await BulkResourceCreationService.CreateCiphersAsync(connection, transaction, ciphers); await connection.ExecuteAsync( $"[{Schema}].[User_BumpAccountRevisionDate]", @@ -551,41 +542,21 @@ public class CipherRepository : Repository, ICipherRepository { try { - using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) - { - bulkCopy.DestinationTableName = "[dbo].[Cipher]"; - var dataTable = BuildCiphersTable(bulkCopy, ciphers); - bulkCopy.WriteToServer(dataTable); - } + await BulkResourceCreationService.CreateCiphersAsync(connection, transaction, ciphers); if (collections.Any()) { - using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) - { - bulkCopy.DestinationTableName = "[dbo].[Collection]"; - var dataTable = BuildCollectionsTable(bulkCopy, collections); - bulkCopy.WriteToServer(dataTable); - } + await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections); } if (collectionCiphers.Any()) { - using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) - { - bulkCopy.DestinationTableName = "[dbo].[CollectionCipher]"; - var dataTable = BuildCollectionCiphersTable(bulkCopy, collectionCiphers); - bulkCopy.WriteToServer(dataTable); - } + await BulkResourceCreationService.CreateCollectionCiphersAsync(connection, transaction, collectionCiphers); } if (collectionUsers.Any()) { - using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) - { - bulkCopy.DestinationTableName = "[dbo].[CollectionUser]"; - var dataTable = BuildCollectionUsersTable(bulkCopy, collectionUsers); - bulkCopy.WriteToServer(dataTable); - } + await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers); } await connection.ExecuteAsync( @@ -615,6 +586,19 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task UnarchiveAsync(IEnumerable ids, Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteScalarAsync( + $"[{Schema}].[Cipher_Unarchive]", + new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results; + } + } + public async Task RestoreAsync(IEnumerable ids, Guid userId) { using (var connection = new SqlConnection(ConnectionString)) @@ -653,6 +637,50 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task> + GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(Guid orgId) + { + await using var connection = new SqlConnection(ConnectionString); + + var dict = new Dictionary(); + var tempCollections = new Dictionary>(); + + await connection.QueryAsync< + CipherOrganizationDetails, + CollectionCipher, + CipherOrganizationDetailsWithCollections + >( + $"[{Schema}].[CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections]", + (cipher, cc) => + { + if (!dict.TryGetValue(cipher.Id, out var details)) + { + details = new CipherOrganizationDetailsWithCollections(cipher, new Dictionary>()); + dict.Add(cipher.Id, details); + tempCollections[cipher.Id] = new List(); + } + + if (cc?.CollectionId != null) + { + tempCollections[cipher.Id].AddIfNotExists(cc.CollectionId); + } + + return details; + }, + new { OrganizationId = orgId }, + splitOn: "CollectionId", + commandType: CommandType.StoredProcedure + ); + + foreach (var kv in dict) + { + kv.Value.CollectionIds = tempCollections[kv.Key].ToArray(); + } + + return dict.Values.ToList(); + } + + private DataTable BuildCiphersTable(SqlBulkCopy bulkCopy, IEnumerable ciphers) { var c = ciphers.FirstOrDefault(); diff --git a/src/Infrastructure.Dapper/Vault/Repositories/FolderRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/FolderRepository.cs index a6f6f2ee22..63da064f88 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/FolderRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/FolderRepository.cs @@ -1,4 +1,7 @@ -using System.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Settings; using Bit.Core.Vault.Entities; diff --git a/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs index f7a5f3b878..292e99d6ad 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs @@ -48,6 +48,19 @@ public class SecurityTaskRepository : Repository, ISecurityT return results.ToList(); } + /// + public async Task GetTaskMetricsAsync(Guid organizationId) + { + await using var connection = new SqlConnection(ConnectionString); + + var result = await connection.QueryAsync( + $"[{Schema}].[SecurityTask_ReadMetricsByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return result.FirstOrDefault() ?? new SecurityTaskMetrics(0, 0); + } + /// public async Task> CreateManyAsync(IEnumerable tasks) { diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs index 76e9b2e912..98f10394f4 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs @@ -12,9 +12,16 @@ public class EventEntityTypeConfiguration : IEntityTypeConfiguration .Property(e => e.Id) .ValueGeneratedNever(); - builder - .HasIndex(e => new { e.Date, e.OrganizationId, e.ActingUserId, e.CipherId }) - .IsClustered(false); + builder.HasKey(e => e.Id) + .IsClustered(); + + var index = builder.HasIndex(e => new { e.Date, e.OrganizationId, e.ActingUserId, e.CipherId }) + .IsClustered(false) + .HasDatabaseName("IX_Event_DateOrganizationIdUserId"); + + SqlServerIndexBuilderExtensions.IncludeProperties( + index, + e => new { e.ServiceAccountId, e.GrantedServiceAccountId }); builder.ToTable(nameof(Event)); } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs index d7f83d829d..ac50b64f3a 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/Organization.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations; using Bit.Infrastructure.EntityFramework.Auth.Models; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs index db81b81166..0f47d5947b 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs @@ -4,7 +4,7 @@ namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models; public class OrganizationIntegration : Core.AdminConsole.Entities.OrganizationIntegration { - public virtual Organization Organization { get; set; } + public virtual required Organization Organization { get; set; } } public class OrganizationIntegrationMapperProfile : Profile diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs index 465a49dc02..21b282f767 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs @@ -4,7 +4,7 @@ namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models; public class OrganizationIntegrationConfiguration : Core.AdminConsole.Entities.OrganizationIntegrationConfiguration { - public virtual OrganizationIntegration OrganizationIntegration { get; set; } + public virtual required OrganizationIntegration OrganizationIntegration { get; set; } } public class OrganizationIntegrationConfigurationMapperProfile : Profile diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/Policy.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/Policy.cs index 0685789e2b..e58e2874e5 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/Policy.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/Policy.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/Provider/ProviderOrganization.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/Provider/ProviderOrganization.cs index e02dfbefec..d00ecf7277 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/Provider/ProviderOrganization.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/Provider/ProviderOrganization.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/Provider/ProviderUser.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/Provider/ProviderUser.cs index 1b6de00960..af2d79a4aa 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/Provider/ProviderUser.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/Provider/ProviderUser.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.Models; namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/EventRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/EventRepository.cs index 55aad0a3c5..0a79782b91 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/EventRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/EventRepository.cs @@ -1,6 +1,7 @@ using AutoMapper; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Entities; using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Repositories.Queries; using LinqToDB.EntityFrameworkCore; @@ -77,6 +78,57 @@ public class EventRepository : Repository, IEv return result; } + public async Task> GetManyBySecretAsync(Secret secret, + DateTime startDate, DateTime endDate, PageOptions pageOptions) + { + DateTime? beforeDate = null; + if (!string.IsNullOrWhiteSpace(pageOptions.ContinuationToken) && + long.TryParse(pageOptions.ContinuationToken, out var binaryDate)) + { + beforeDate = DateTime.SpecifyKind(DateTime.FromBinary(binaryDate), DateTimeKind.Utc); + } + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = new EventReadPageBySecretQuery(secret, startDate, endDate, beforeDate, pageOptions); + var events = await query.Run(dbContext).ToListAsync(); + + var result = new PagedResult(); + if (events.Any() && events.Count >= pageOptions.PageSize) + { + result.ContinuationToken = events.Last().Date.ToBinary().ToString(); + } + result.Data.AddRange(events); + return result; + } + } + + public async Task> GetManyByProjectAsync(Project project, + DateTime startDate, DateTime endDate, PageOptions pageOptions) + { + DateTime? beforeDate = null; + if (!string.IsNullOrWhiteSpace(pageOptions.ContinuationToken) && + long.TryParse(pageOptions.ContinuationToken, out var binaryDate)) + { + beforeDate = DateTime.SpecifyKind(DateTime.FromBinary(binaryDate), DateTimeKind.Utc); + } + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = new EventReadPageByProjectQuery(project, startDate, endDate, beforeDate, pageOptions); + var events = await query.Run(dbContext).ToListAsync(); + + var result = new PagedResult(); + if (events.Any() && events.Count >= pageOptions.PageSize) + { + result.ContinuationToken = events.Last().Date.ToBinary().ToString(); + } + result.Data.AddRange(events); + return result; + } + } + + public async Task> GetManyByCipherAsync(Cipher cipher, DateTime startDate, DateTime endDate, PageOptions pageOptions) { DateTime? beforeDate = null; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs index 305a715d4c..3b6ea749fa 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Models.Data; using Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs index f051830035..fc391b958c 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationConfigurationRepository.cs @@ -3,6 +3,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; +using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories.Queries; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Infrastructure.EntityFramework.Repositories.Queries; using Microsoft.EntityFrameworkCore; @@ -30,4 +31,27 @@ public class OrganizationIntegrationConfigurationRepository : Repository> GetAllConfigurationDetailsAsync() + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = new OrganizationIntegrationConfigurationDetailsReadManyQuery(); + return await query.Run(dbContext).ToListAsync(); + } + } + + public async Task> GetManyByIntegrationAsync( + Guid organizationIntegrationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = new OrganizationIntegrationConfigurationReadManyByOrganizationIntegrationIdQuery( + organizationIntegrationId + ); + return await query.Run(dbContext).ToListAsync(); + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationRepository.cs index 816ad3b25f..5670b2ae9b 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationRepository.cs @@ -1,14 +1,29 @@ using AutoMapper; using Bit.Core.Repositories; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; +using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories.Queries; using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace Bit.Infrastructure.EntityFramework.AdminConsole.Repositories; -public class OrganizationIntegrationRepository : Repository, IOrganizationIntegrationRepository +public class OrganizationIntegrationRepository : + Repository, + IOrganizationIntegrationRepository { public OrganizationIntegrationRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.OrganizationIntegrations) - { } + { + } + + public async Task> GetManyByOrganizationAsync(Guid organizationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = new OrganizationIntegrationReadManyByOrganizationIdQuery(organizationId); + return await query.Run(dbContext).ToListAsync(); + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index c378fe5e7e..200c4aa308 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using AutoMapper.QueryableExtensions; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Constants; @@ -400,4 +403,41 @@ public class OrganizationRepository : Repository> GetOrganizationsForSubscriptionSyncAsync() + { + using var scope = ServiceScopeFactory.CreateScope(); + await using var dbContext = GetDatabaseContext(scope); + + var organizations = await dbContext.Organizations + .Where(o => o.SyncSeats == true && o.Seats != null) + .ToArrayAsync(); + + return organizations; + } + + public async Task UpdateSuccessfulOrganizationSyncStatusAsync(IEnumerable successfulOrganizations, DateTime syncDate) + { + using var scope = ServiceScopeFactory.CreateScope(); + await using var dbContext = GetDatabaseContext(scope); + + await dbContext.Organizations + .Where(o => successfulOrganizations.Contains(o.Id)) + .ExecuteUpdateAsync(o => o + .SetProperty(x => x.SyncSeats, false) + .SetProperty(x => x.RevisionDate, syncDate.Date)); + } + + public async Task IncrementSeatCountAsync(Guid organizationId, int increaseAmount, DateTime requestDate) + { + using var scope = ServiceScopeFactory.CreateScope(); + await using var dbContext = GetDatabaseContext(scope); + + await dbContext.Organizations + .Where(o => o.Id == organizationId) + .ExecuteUpdateAsync(s => s + .SetProperty(o => o.Seats, o => o.Seats + increaseAmount) + .SetProperty(o => o.SyncSeats, true) + .SetProperty(o => o.RevisionDate, requestDate)); + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index 26a72bb991..fae0598c1c 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -1,7 +1,11 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; @@ -70,53 +74,91 @@ public class OrganizationUserRepository : Repository u.Id).ToList(); } - public override async Task DeleteAsync(Core.Entities.OrganizationUser organizationUser) => await DeleteAsync(organizationUser.Id); - public async Task DeleteAsync(Guid organizationUserId) + public override async Task DeleteAsync(Core.Entities.OrganizationUser organizationUser) { using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdAsync(organizationUserId); var orgUser = await dbContext.OrganizationUsers - .Where(ou => ou.Id == organizationUserId) - .FirstAsync(); + .Where(ou => ou.Id == organizationUser.Id) + .Select(ou => new + { + ou.Id, + ou.UserId, + OrgEmail = ou.Email, + UserEmail = ou.User.Email + }) + .FirstOrDefaultAsync(); - var organizationId = orgUser?.OrganizationId; + if (orgUser == null) + { + throw new NotFoundException("User not found."); + } + + var email = !string.IsNullOrEmpty(orgUser.OrgEmail) + ? orgUser.OrgEmail + : orgUser.UserEmail; + var organizationId = organizationUser?.OrganizationId; var userId = orgUser?.UserId; + var utcNow = DateTime.UtcNow; - if (orgUser?.OrganizationId != null && orgUser?.UserId != null) + using var transaction = await dbContext.Database.BeginTransactionAsync(); + + try { - var ssoUsers = dbContext.SsoUsers - .Where(su => su.UserId == userId && su.OrganizationId == organizationId); - dbContext.SsoUsers.RemoveRange(ssoUsers); + await dbContext.Collections + .Where(c => c.Type == CollectionType.DefaultUserCollection + && c.CollectionUsers.Any(cu => cu.OrganizationUserId == organizationUser.Id)) + .ExecuteUpdateAsync(setters => setters + .SetProperty(c => c.Type, CollectionType.SharedCollection) + .SetProperty(c => c.RevisionDate, utcNow) + .SetProperty(c => c.DefaultUserCollectionEmail, + c => c.DefaultUserCollectionEmail == null ? email : c.DefaultUserCollectionEmail)); + + await dbContext.CollectionUsers + .Where(cu => cu.OrganizationUserId == organizationUser.Id) + .ExecuteDeleteAsync(); + + await dbContext.GroupUsers + .Where(gu => gu.OrganizationUserId == organizationUser.Id) + .ExecuteDeleteAsync(); + + await dbContext.SsoUsers + .Where(su => su.UserId == userId && su.OrganizationId == organizationId) + .ExecuteDeleteAsync(); + + await dbContext.UserProjectAccessPolicy + .Where(ap => ap.OrganizationUserId == organizationUser.Id) + .ExecuteDeleteAsync(); + + await dbContext.UserServiceAccountAccessPolicy + .Where(ap => ap.OrganizationUserId == organizationUser.Id) + .ExecuteDeleteAsync(); + + await dbContext.UserSecretAccessPolicy + .Where(ap => ap.OrganizationUserId == organizationUser.Id) + .ExecuteDeleteAsync(); + + await dbContext.OrganizationSponsorships + .Where(os => os.SponsoringOrganizationUserId == organizationUser.Id) + .ExecuteDeleteAsync(); + + await dbContext.Users + .Where(u => u.Id == orgUser.UserId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(u => u.AccountRevisionDate, utcNow)); + + await dbContext.OrganizationUsers + .Where(ou => ou.Id == organizationUser.Id) + .ExecuteDeleteAsync(); + + await transaction.CommitAsync(); } - - var collectionUsers = dbContext.CollectionUsers - .Where(cu => cu.OrganizationUserId == organizationUserId); - dbContext.CollectionUsers.RemoveRange(collectionUsers); - - var groupUsers = dbContext.GroupUsers - .Where(gu => gu.OrganizationUserId == organizationUserId); - dbContext.GroupUsers.RemoveRange(groupUsers); - - dbContext.UserProjectAccessPolicy.RemoveRange( - dbContext.UserProjectAccessPolicy.Where(ap => ap.OrganizationUserId == organizationUserId)); - dbContext.UserServiceAccountAccessPolicy.RemoveRange( - dbContext.UserServiceAccountAccessPolicy.Where(ap => ap.OrganizationUserId == organizationUserId)); - dbContext.UserSecretAccessPolicy.RemoveRange( - dbContext.UserSecretAccessPolicy.Where(ap => ap.OrganizationUserId == organizationUserId)); - - var orgSponsorships = await dbContext.OrganizationSponsorships - .Where(os => os.SponsoringOrganizationUserId == organizationUserId) - .ToListAsync(); - - foreach (var orgSponsorship in orgSponsorships) + catch { - orgSponsorship.ToDelete = true; + await transaction.RollbackAsync(); + throw; } - - dbContext.OrganizationUsers.Remove(orgUser); - await dbContext.SaveChangesAsync(); } } @@ -127,31 +169,92 @@ public class OrganizationUserRepository : Repository targetOrganizationUserIds.Contains(cu.OrganizationUserId)) - .ExecuteDeleteAsync(); + try + { + await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdsAsync(targetOrganizationUserIds); - await dbContext.GroupUsers - .Where(gu => targetOrganizationUserIds.Contains(gu.OrganizationUserId)) - .ExecuteDeleteAsync(); + var organizationUsersToDelete = await dbContext.OrganizationUsers + .Where(ou => targetOrganizationUserIds.Contains(ou.Id)) + .Include(ou => ou.User) + .ToListAsync(); - await dbContext.UserProjectAccessPolicy - .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) - .ExecuteDeleteAsync(); - await dbContext.UserServiceAccountAccessPolicy - .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) - .ExecuteDeleteAsync(); - await dbContext.UserSecretAccessPolicy - .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) - .ExecuteDeleteAsync(); + var collectionUsers = await dbContext.CollectionUsers + .Where(cu => targetOrganizationUserIds.Contains(cu.OrganizationUserId)) + .ToListAsync(); - await dbContext.OrganizationUsers - .Where(ou => targetOrganizationUserIds.Contains(ou.Id)).ExecuteDeleteAsync(); + var collectionIds = collectionUsers.Select(cu => cu.CollectionId).Distinct().ToList(); - await dbContext.SaveChangesAsync(); - await transaction.CommitAsync(); + var collections = await dbContext.Collections + .Where(c => collectionIds.Contains(c.Id)) + .ToListAsync(); + + var collectionsToUpdate = collections + .Where(c => c.Type == CollectionType.DefaultUserCollection) + .ToList(); + + var collectionUserLookup = collectionUsers.ToLookup(cu => cu.CollectionId); + + foreach (var collection in collectionsToUpdate) + { + var collectionUser = collectionUserLookup[collection.Id].FirstOrDefault(); + if (collectionUser != null) + { + var orgUser = organizationUsersToDelete.FirstOrDefault(ou => ou.Id == collectionUser.OrganizationUserId); + + if (orgUser?.User != null) + { + if (string.IsNullOrEmpty(collection.DefaultUserCollectionEmail)) + { + var emailToUse = !string.IsNullOrEmpty(orgUser.Email) + ? orgUser.Email + : orgUser.User.Email; + + if (!string.IsNullOrEmpty(emailToUse)) + { + collection.DefaultUserCollectionEmail = emailToUse; + } + } + collection.Type = CollectionType.SharedCollection; + } + } + } + + await dbContext.CollectionUsers + .Where(cu => targetOrganizationUserIds.Contains(cu.OrganizationUserId)) + .ExecuteDeleteAsync(); + + await dbContext.GroupUsers + .Where(gu => targetOrganizationUserIds.Contains(gu.OrganizationUserId)) + .ExecuteDeleteAsync(); + + await dbContext.UserProjectAccessPolicy + .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) + .ExecuteDeleteAsync(); + + await dbContext.UserServiceAccountAccessPolicy + .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) + .ExecuteDeleteAsync(); + + await dbContext.UserSecretAccessPolicy + .Where(ap => targetOrganizationUserIds.Contains(ap.OrganizationUserId!.Value)) + .ExecuteDeleteAsync(); + + await dbContext.OrganizationSponsorships + .Where(os => targetOrganizationUserIds.Contains(os.SponsoringOrganizationUserId)) + .ExecuteDeleteAsync(); + + await dbContext.OrganizationUsers + .Where(ou => targetOrganizationUserIds.Contains(ou.Id)).ExecuteDeleteAsync(); + + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } } public async Task>> GetByIdWithCollectionsAsync(Guid id) @@ -254,7 +357,8 @@ public class OrganizationUserRepository : Repository new CollectionAccessSelection { @@ -366,6 +470,8 @@ public class OrganizationUserRepository : Repository c.OrganizationUserId).ToList(); } @@ -398,6 +504,58 @@ public class OrganizationUserRepository : Repository> GetManyDetailsByOrganizationAsync_vNext( + Guid organizationId, bool includeGroups, bool includeCollections) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var query = from ou in dbContext.OrganizationUsers + where ou.OrganizationId == organizationId + select new OrganizationUserUserDetails + { + Id = ou.Id, + UserId = ou.UserId, + OrganizationId = ou.OrganizationId, + Name = ou.User.Name, + Email = ou.User.Email ?? ou.Email, + AvatarColor = ou.User.AvatarColor, + TwoFactorProviders = ou.User.TwoFactorProviders, + Premium = ou.User.Premium, + Status = ou.Status, + Type = ou.Type, + ExternalId = ou.ExternalId, + SsoExternalId = ou.User.SsoUsers + .Where(su => su.OrganizationId == ou.OrganizationId) + .Select(su => su.ExternalId) + .FirstOrDefault(), + Permissions = ou.Permissions, + ResetPasswordKey = ou.ResetPasswordKey, + UsesKeyConnector = ou.User != null && ou.User.UsesKeyConnector, + AccessSecretsManager = ou.AccessSecretsManager, + HasMasterPassword = ou.User != null && !string.IsNullOrWhiteSpace(ou.User.MasterPassword), + + // Project directly from navigation properties with conditional loading + Groups = includeGroups + ? ou.GroupUsers.Select(gu => gu.GroupId).ToList() + : new List(), + + Collections = includeCollections + ? ou.CollectionUsers + .Where(cu => cu.Collection.Type == CollectionType.SharedCollection) + .Select(cu => new CollectionAccessSelection + { + Id = cu.CollectionId, + ReadOnly = cu.ReadOnly, + HidePasswords = cu.HidePasswords, + Manage = cu.Manage + }).ToList() + : new List() + }; + + return await query.ToListAsync(); + } + public async Task> GetManyDetailsByUserAsync(Guid userId, OrganizationUserStatusType? status = null) { @@ -458,9 +616,11 @@ public class OrganizationUserRepository : Repository cu.OrganizationUserId == obj.Id) - .ToListAsync(); + // Retrieve all collection assignments, excluding DefaultUserCollection + var existingCollectionUsers = await (from cu in dbContext.CollectionUsers + join c in dbContext.Collections on cu.CollectionId equals c.Id + where cu.OrganizationUserId == obj.Id && c.Type != CollectionType.DefaultUserCollection + select cu).ToListAsync(); foreach (var requestedCollection in requestedCollections) { diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs index 0564681341..72c277f1d7 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Repositories; @@ -91,4 +94,183 @@ public class PolicyRepository : Repository> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var givenOrgUsers = + from ou in dbContext.OrganizationUsers + where ou.OrganizationId == organizationId + from u in dbContext.Users + where + (u.Email == ou.Email && ou.Email != null) + || (ou.UserId == u.Id && ou.UserId != null) + + select new + { + ou.Id, + ou.OrganizationId, + UserId = u.Id, + u.Email + }; + + var orgUsersLinkedByUserId = + from ou in dbContext.OrganizationUsers + join gou in givenOrgUsers + on ou.UserId equals gou.UserId + select new + { + ou.Id, + ou.OrganizationId, + gou.UserId, + ou.Type, + ou.Status, + ou.Permissions + }; + + var orgUsersLinkedByEmail = + from ou in dbContext.OrganizationUsers + join gou in givenOrgUsers + on ou.Email equals gou.Email + select new + { + ou.Id, + ou.OrganizationId, + gou.UserId, + ou.Type, + ou.Status, + ou.Permissions + }; + + var allAffectedOrgUsers = orgUsersLinkedByEmail.Union(orgUsersLinkedByUserId); + + var providerOrganizations = from pu in dbContext.ProviderUsers + join po in dbContext.ProviderOrganizations + on pu.ProviderId equals po.ProviderId + join ou in allAffectedOrgUsers + on pu.UserId equals ou.UserId + where pu.UserId == ou.UserId + select new + { + pu.UserId, + po.OrganizationId + }; + + var policyWithAffectedUsers = + from p in dbContext.Policies + join o in dbContext.Organizations + on p.OrganizationId equals o.Id + join ou in allAffectedOrgUsers + on o.Id equals ou.OrganizationId + where p.Enabled + && o.Enabled + && o.UsePolicies + && p.Type == policyType + select new OrganizationPolicyDetails + { + UserId = ou.UserId, + OrganizationUserId = ou.Id, + OrganizationId = p.OrganizationId, + PolicyType = p.Type, + PolicyData = p.Data, + OrganizationUserType = ou.Type, + OrganizationUserStatus = ou.Status, + OrganizationUserPermissionsData = ou.Permissions, + IsProvider = providerOrganizations.Any(po => po.OrganizationId == p.OrganizationId) + }; + + return await policyWithAffectedUsers.ToListAsync(); + } + + public async Task> GetPolicyDetailsByUserIdsAndPolicyType( + IEnumerable userIds, PolicyType policyType) + { + ArgumentNullException.ThrowIfNull(userIds); + + var userIdsList = userIds.Where(id => id != Guid.Empty).ToList(); + + if (userIdsList.Count == 0) + { + return []; + } + + using var scope = ServiceScopeFactory.CreateScope(); + await using var dbContext = GetDatabaseContext(scope); + + // Get provider relationships + var providerLookup = await (from pu in dbContext.ProviderUsers + join po in dbContext.ProviderOrganizations on pu.ProviderId equals po.ProviderId + where pu.UserId != null && userIdsList.Contains(pu.UserId.Value) + select new { pu.UserId, po.OrganizationId }) + .ToListAsync(); + + // Hashset for lookup + var providerSet = new HashSet<(Guid UserId, Guid OrganizationId)>( + providerLookup.Select(p => (p.UserId!.Value, p.OrganizationId))); + + // Branch 1: Accepted users + var acceptedUsers = await (from p in dbContext.Policies + join ou in dbContext.OrganizationUsers on p.OrganizationId equals ou.OrganizationId + join o in dbContext.Organizations on p.OrganizationId equals o.Id + where p.Enabled + && p.Type == policyType + && o.Enabled + && o.UsePolicies + && ou.Status != OrganizationUserStatusType.Invited + && ou.UserId != null + && userIdsList.Contains(ou.UserId.Value) + select new + { + OrganizationUserId = ou.Id, + OrganizationId = p.OrganizationId, + PolicyType = p.Type, + PolicyData = p.Data, + OrganizationUserType = ou.Type, + OrganizationUserStatus = ou.Status, + OrganizationUserPermissionsData = ou.Permissions, + UserId = ou.UserId.Value + }).ToListAsync(); + + // Branch 2: Invited users + var invitedUsers = await (from p in dbContext.Policies + join ou in dbContext.OrganizationUsers on p.OrganizationId equals ou.OrganizationId + join o in dbContext.Organizations on p.OrganizationId equals o.Id + join u in dbContext.Users on ou.Email equals u.Email + where p.Enabled + && o.Enabled + && o.UsePolicies + && ou.Status == OrganizationUserStatusType.Invited + && userIdsList.Contains(u.Id) + && p.Type == policyType + select new + { + OrganizationUserId = ou.Id, + OrganizationId = p.OrganizationId, + PolicyType = p.Type, + PolicyData = p.Data, + OrganizationUserType = ou.Type, + OrganizationUserStatus = ou.Status, + OrganizationUserPermissionsData = ou.Permissions, + UserId = u.Id + }).ToListAsync(); + + // Combine results with provder lookup + var allResults = acceptedUsers.Concat(invitedUsers) + .Select(item => new OrganizationPolicyDetails + { + OrganizationUserId = item.OrganizationUserId, + OrganizationId = item.OrganizationId, + PolicyType = item.PolicyType, + PolicyData = item.PolicyData, + OrganizationUserType = item.OrganizationUserType, + OrganizationUserStatus = item.OrganizationUserStatus, + OrganizationUserPermissionsData = item.OrganizationUserPermissionsData, + UserId = item.UserId, + IsProvider = providerSet.Contains((item.UserId, item.OrganizationId)) + }); + + return allResults.ToList(); + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderOrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderOrganizationRepository.cs index 77f5f8edc1..f9ef44fb9a 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderOrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderOrganizationRepository.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderRepository.cs index 3c2ac73b83..2a9b3b8abe 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderRepository.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderUserRepository.cs index ad4422da63..5474e3e217 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/ProviderUserRepository.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs index 01f3a1fe14..72dc8db386 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs @@ -30,7 +30,7 @@ public class EventReadPageByOrganizationIdServiceAccountIdQuery : IQuery (_beforeDate != null || e.Date <= _endDate) && (_beforeDate == null || e.Date < _beforeDate.Value) && e.OrganizationId == _organizationId && - e.ServiceAccountId == _serviceAccountId + (e.ServiceAccountId == _serviceAccountId || e.GrantedServiceAccountId == _serviceAccountId) orderby e.Date descending select e; return q.Skip(0).Take(_pageOptions.PageSize); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProjectIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProjectIdQuery.cs new file mode 100644 index 0000000000..8c66132600 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByProjectIdQuery.cs @@ -0,0 +1,49 @@ +using Bit.Core.Models.Data; +using Bit.Core.SecretsManager.Entities; +using Event = Bit.Infrastructure.EntityFramework.Models.Event; + +namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; + +public class EventReadPageByProjectQuery : IQuery +{ + private readonly Project _project; + private readonly DateTime _startDate; + private readonly DateTime _endDate; + private readonly DateTime? _beforeDate; + private readonly PageOptions _pageOptions; + + public EventReadPageByProjectQuery(Project project, DateTime startDate, DateTime endDate, PageOptions pageOptions) + { + _project = project; + _startDate = startDate; + _endDate = endDate; + _beforeDate = null; + _pageOptions = pageOptions; + } + + public EventReadPageByProjectQuery(Project project, DateTime startDate, DateTime endDate, DateTime? beforeDate, PageOptions pageOptions) + { + _project = project; + _startDate = startDate; + _endDate = endDate; + _beforeDate = beforeDate; + _pageOptions = pageOptions; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var emptyGuid = Guid.Empty; + var q = from e in dbContext.Events + where e.Date >= _startDate && + (_beforeDate == null || e.Date < _beforeDate.Value) && + ( + (_project.OrganizationId == emptyGuid && !e.OrganizationId.HasValue) || + (_project.OrganizationId != emptyGuid && e.OrganizationId == _project.OrganizationId) + ) && + e.ProjectId == _project.Id + orderby e.Date descending + select e; + + return q.Take(_pageOptions.PageSize); + } +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageBySecretIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageBySecretIdQuery.cs new file mode 100644 index 0000000000..7ddf0c4589 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageBySecretIdQuery.cs @@ -0,0 +1,49 @@ +using Bit.Core.Models.Data; +using Bit.Core.SecretsManager.Entities; +using Event = Bit.Infrastructure.EntityFramework.Models.Event; + +namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; + +public class EventReadPageBySecretQuery : IQuery +{ + private readonly Secret _secret; + private readonly DateTime _startDate; + private readonly DateTime _endDate; + private readonly DateTime? _beforeDate; + private readonly PageOptions _pageOptions; + + public EventReadPageBySecretQuery(Secret secret, DateTime startDate, DateTime endDate, PageOptions pageOptions) + { + _secret = secret; + _startDate = startDate; + _endDate = endDate; + _beforeDate = null; + _pageOptions = pageOptions; + } + + public EventReadPageBySecretQuery(Secret secret, DateTime startDate, DateTime endDate, DateTime? beforeDate, PageOptions pageOptions) + { + _secret = secret; + _startDate = startDate; + _endDate = endDate; + _beforeDate = beforeDate; + _pageOptions = pageOptions; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var emptyGuid = Guid.Empty; + var q = from e in dbContext.Events + where e.Date >= _startDate && + (_beforeDate == null || e.Date < _beforeDate.Value) && + ( + (_secret.OrganizationId == emptyGuid && !e.OrganizationId.HasValue) || + (_secret.OrganizationId != emptyGuid && e.OrganizationId == _secret.OrganizationId) + ) && + e.SecretId == _secret.Id + orderby e.Date descending + select e; + + return q.Take(_pageOptions.PageSize); + } +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs new file mode 100644 index 0000000000..0d1cd6a656 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs @@ -0,0 +1,48 @@ +using Bit.Core.Models.Data; +using Bit.Core.SecretsManager.Entities; +using Event = Bit.Infrastructure.EntityFramework.Models.Event; + +namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; + +public class EventReadPageByServiceAccountQuery : IQuery +{ + private readonly ServiceAccount _serviceAccount; + private readonly DateTime _startDate; + private readonly DateTime _endDate; + private readonly DateTime? _beforeDate; + private readonly PageOptions _pageOptions; + + public EventReadPageByServiceAccountQuery(ServiceAccount serviceAccount, DateTime startDate, DateTime endDate, PageOptions pageOptions) + { + _serviceAccount = serviceAccount; + _startDate = startDate; + _endDate = endDate; + _beforeDate = null; + _pageOptions = pageOptions; + } + + public EventReadPageByServiceAccountQuery(ServiceAccount serviceAccount, DateTime startDate, DateTime endDate, DateTime? beforeDate, PageOptions pageOptions) + { + _serviceAccount = serviceAccount; + _startDate = startDate; + _endDate = endDate; + _beforeDate = beforeDate; + _pageOptions = pageOptions; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var q = from e in dbContext.Events + where e.Date >= _startDate && + (_beforeDate == null || e.Date < _beforeDate.Value) && + ( + (_serviceAccount.OrganizationId == Guid.Empty && !e.OrganizationId.HasValue) || + (_serviceAccount.OrganizationId != Guid.Empty && e.OrganizationId == _serviceAccount.OrganizationId) + ) && + e.GrantedServiceAccountId == _serviceAccount.Id + orderby e.Date descending + select e; + + return q.Take(_pageOptions.PageSize); + } +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery.cs index c816b01a01..b4441c5084 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrganizationIdIntegrationTypeQuery.cs @@ -1,4 +1,6 @@ -using Bit.Core.Enums; +#nullable enable + +using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations; namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; @@ -27,6 +29,7 @@ public class OrganizationIntegrationConfigurationDetailsReadManyByEventTypeOrgan select new OrganizationIntegrationConfigurationDetails() { Id = oic.Id, + OrganizationId = oi.OrganizationId, OrganizationIntegrationId = oic.OrganizationIntegrationId, IntegrationType = oi.Type, EventType = oic.EventType, diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyQuery.cs new file mode 100644 index 0000000000..8141292c81 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationDetailsReadManyQuery.cs @@ -0,0 +1,28 @@ +#nullable enable + +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; + +public class OrganizationIntegrationConfigurationDetailsReadManyQuery : IQuery +{ + public IQueryable Run(DatabaseContext dbContext) + { + var query = from oic in dbContext.OrganizationIntegrationConfigurations + join oi in dbContext.OrganizationIntegrations on oic.OrganizationIntegrationId equals oi.Id into oioic + from oi in dbContext.OrganizationIntegrations + select new OrganizationIntegrationConfigurationDetails() + { + Id = oic.Id, + OrganizationId = oi.OrganizationId, + OrganizationIntegrationId = oic.OrganizationIntegrationId, + IntegrationType = oi.Type, + EventType = oic.EventType, + Configuration = oic.Configuration, + Filters = oic.Filters, + IntegrationConfiguration = oi.Configuration, + Template = oic.Template + }; + return query; + } +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationReadManyByOrganizationIntegrationIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationReadManyByOrganizationIntegrationIdQuery.cs new file mode 100644 index 0000000000..3ed3a48723 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationConfigurationReadManyByOrganizationIntegrationIdQuery.cs @@ -0,0 +1,33 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories.Queries; + +namespace Bit.Infrastructure.EntityFramework.AdminConsole.Repositories.Queries; + +public class OrganizationIntegrationConfigurationReadManyByOrganizationIntegrationIdQuery : IQuery +{ + private readonly Guid _organizationIntegrationId; + + public OrganizationIntegrationConfigurationReadManyByOrganizationIntegrationIdQuery(Guid organizationIntegrationId) + { + _organizationIntegrationId = organizationIntegrationId; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var query = from oic in dbContext.OrganizationIntegrationConfigurations + where oic.OrganizationIntegrationId == _organizationIntegrationId + select new OrganizationIntegrationConfiguration() + { + Id = oic.Id, + OrganizationIntegrationId = oic.OrganizationIntegrationId, + Configuration = oic.Configuration, + EventType = oic.EventType, + Filters = oic.Filters, + Template = oic.Template, + RevisionDate = oic.RevisionDate + }; + return query; + } + +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationReadManyByOrganizationIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationReadManyByOrganizationIdQuery.cs new file mode 100644 index 0000000000..df87ad0bc1 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationReadManyByOrganizationIdQuery.cs @@ -0,0 +1,30 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories.Queries; + +namespace Bit.Infrastructure.EntityFramework.AdminConsole.Repositories.Queries; + +public class OrganizationIntegrationReadManyByOrganizationIdQuery : IQuery +{ + private readonly Guid _organizationId; + + public OrganizationIntegrationReadManyByOrganizationIdQuery(Guid organizationId) + { + _organizationId = organizationId; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var query = from oi in dbContext.OrganizationIntegrations + where oi.OrganizationId == _organizationId + select new OrganizationIntegration() + { + Id = oi.Id, + OrganizationId = oi.OrganizationId, + Type = oi.Type, + Configuration = oi.Configuration, + }; + return query; + } + +} diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs index 71bf113416..26d3a128fc 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs @@ -56,6 +56,7 @@ public class OrganizationUserOrganizationDetailsViewQuery : IQuery, IAuthRequestRepository { - public AuthRequestRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) - : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.AuthRequests) - { } + private readonly IGlobalSettings _globalSettings; + public AuthRequestRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper, IGlobalSettings globalSettings) + : base(serviceScopeFactory, mapper, context => context.AuthRequests) + { + _globalSettings = globalSettings; + } + public async Task DeleteExpiredAsync( TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration) { @@ -57,6 +62,32 @@ public class AuthRequestRepository : Repository> GetManyPendingAuthRequestByUserId(Guid userId) + { + var expirationMinutes = (int)_globalSettings.PasswordlessAuth.UserRequestExpiration.TotalMinutes; + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var mostRecentAuthRequests = await + (from authRequest in dbContext.AuthRequests + where authRequest.Type == AuthRequestType.AuthenticateAndUnlock + || authRequest.Type == AuthRequestType.Unlock + where authRequest.UserId == userId + where authRequest.CreationDate.AddMinutes(expirationMinutes) >= DateTime.UtcNow + group authRequest by authRequest.RequestDeviceIdentifier into groupedAuthRequests + select + (from r in groupedAuthRequests + join d in dbContext.Devices on new { r.RequestDeviceIdentifier, r.UserId } + equals new { RequestDeviceIdentifier = d.Identifier, d.UserId } into deviceJoin + from dj in deviceJoin.DefaultIfEmpty() // This creates a left join allowing null for devices + orderby r.CreationDate descending + select new PendingAuthRequestDetails(r, dj.Id)).First() + ).ToListAsync(); + + mostRecentAuthRequests.RemoveAll(a => a.Approved != null); + + return mostRecentAuthRequests; + } + public async Task> GetManyAdminApprovalRequestsByManyIdsAsync( Guid organizationId, IEnumerable ids) diff --git a/src/Infrastructure.EntityFramework/Billing/Models/OrganizationInstallation.cs b/src/Infrastructure.EntityFramework/Billing/Models/OrganizationInstallation.cs index c59a2accba..4c3de7a899 100644 --- a/src/Infrastructure.EntityFramework/Billing/Models/OrganizationInstallation.cs +++ b/src/Infrastructure.EntityFramework/Billing/Models/OrganizationInstallation.cs @@ -1,10 +1,13 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; using Bit.Infrastructure.EntityFramework.Platform; namespace Bit.Infrastructure.EntityFramework.Billing.Models; -public class OrganizationInstallation : Core.Billing.Entities.OrganizationInstallation +public class OrganizationInstallation : Core.Billing.Organizations.Entities.OrganizationInstallation { public virtual Installation Installation { get; set; } public virtual Organization Organization { get; set; } @@ -14,6 +17,6 @@ public class OrganizationInstallationMapperProfile : Profile { public OrganizationInstallationMapperProfile() { - CreateMap().ReverseMap(); + CreateMap().ReverseMap(); } } diff --git a/src/Infrastructure.EntityFramework/Billing/Models/ProviderInvoiceItem.cs b/src/Infrastructure.EntityFramework/Billing/Models/ProviderInvoiceItem.cs index 1bea786f21..2fdd27868e 100644 --- a/src/Infrastructure.EntityFramework/Billing/Models/ProviderInvoiceItem.cs +++ b/src/Infrastructure.EntityFramework/Billing/Models/ProviderInvoiceItem.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; namespace Bit.Infrastructure.EntityFramework.Billing.Models; diff --git a/src/Infrastructure.EntityFramework/Billing/Models/ProviderPlan.cs b/src/Infrastructure.EntityFramework/Billing/Models/ProviderPlan.cs index c9ba4c813e..7bdb7298e4 100644 --- a/src/Infrastructure.EntityFramework/Billing/Models/ProviderPlan.cs +++ b/src/Infrastructure.EntityFramework/Billing/Models/ProviderPlan.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; namespace Bit.Infrastructure.EntityFramework.Billing.Models; diff --git a/src/Infrastructure.EntityFramework/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs b/src/Infrastructure.EntityFramework/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs index 4a9a82c9dc..d6363155f0 100644 --- a/src/Infrastructure.EntityFramework/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs +++ b/src/Infrastructure.EntityFramework/Billing/Repositories/ClientOrganizationMigrationRecordRepository.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; diff --git a/src/Infrastructure.EntityFramework/Billing/Repositories/OrganizationInstallationRepository.cs b/src/Infrastructure.EntityFramework/Billing/Repositories/OrganizationInstallationRepository.cs index 566c52332e..c03df8d216 100644 --- a/src/Infrastructure.EntityFramework/Billing/Repositories/OrganizationInstallationRepository.cs +++ b/src/Infrastructure.EntityFramework/Billing/Repositories/OrganizationInstallationRepository.cs @@ -1,6 +1,9 @@ -using AutoMapper; -using Bit.Core.Billing.Entities; -using Bit.Core.Billing.Repositories; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; +using Bit.Core.Billing.Organizations.Entities; +using Bit.Core.Billing.Organizations.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Infrastructure.EntityFramework/Converters/DataProtectionConverter.cs b/src/Infrastructure.EntityFramework/Converters/DataProtectionConverter.cs index ee5c23fa71..a3c55fd536 100644 --- a/src/Infrastructure.EntityFramework/Converters/DataProtectionConverter.cs +++ b/src/Infrastructure.EntityFramework/Converters/DataProtectionConverter.cs @@ -1,4 +1,7 @@ -using Bit.Core; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core; using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; diff --git a/src/Infrastructure.EntityFramework/Dirt/Models/OrganizationApplication.cs b/src/Infrastructure.EntityFramework/Dirt/Models/OrganizationApplication.cs index 9aaec0af2c..743345f7fd 100644 --- a/src/Infrastructure.EntityFramework/Dirt/Models/OrganizationApplication.cs +++ b/src/Infrastructure.EntityFramework/Dirt/Models/OrganizationApplication.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.Dirt.Models; diff --git a/src/Infrastructure.EntityFramework/Dirt/Models/OrganizationReport.cs b/src/Infrastructure.EntityFramework/Dirt/Models/OrganizationReport.cs index a7d08e142f..0b58d433ff 100644 --- a/src/Infrastructure.EntityFramework/Dirt/Models/OrganizationReport.cs +++ b/src/Infrastructure.EntityFramework/Dirt/Models/OrganizationReport.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.Dirt.Models; diff --git a/src/Infrastructure.EntityFramework/Dirt/Models/PasswordHealthReportApplication.cs b/src/Infrastructure.EntityFramework/Dirt/Models/PasswordHealthReportApplication.cs index bc471f0844..e8fc818b28 100644 --- a/src/Infrastructure.EntityFramework/Dirt/Models/PasswordHealthReportApplication.cs +++ b/src/Infrastructure.EntityFramework/Dirt/Models/PasswordHealthReportApplication.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.Dirt.Models; diff --git a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs index 416fd91933..525c5a479d 100644 --- a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs +++ b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs @@ -1,5 +1,9 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using LinqToDB; @@ -16,18 +20,6 @@ public class OrganizationReportRepository : IMapper mapper) : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.OrganizationReports) { } - public async Task> GetByOrganizationIdAsync(Guid organizationId) - { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - var results = await dbContext.OrganizationReports - .Where(p => p.OrganizationId == organizationId) - .ToListAsync(); - return Mapper.Map>(results); - } - } - public async Task GetLatestByOrganizationIdAsync(Guid organizationId) { using (var scope = ServiceScopeFactory.CreateScope()) @@ -35,14 +27,161 @@ public class OrganizationReportRepository : var dbContext = GetDatabaseContext(scope); var result = await dbContext.OrganizationReports .Where(p => p.OrganizationId == organizationId) - .OrderByDescending(p => p.Date) + .OrderByDescending(p => p.RevisionDate) .Take(1) .FirstOrDefaultAsync(); - if (result == null) - return default; + if (result == null) return default; return Mapper.Map(result); } } + + public async Task UpdateSummaryDataAsync(Guid organizationId, Guid reportId, string summaryData) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + // Update only SummaryData and RevisionDate + await dbContext.OrganizationReports + .Where(p => p.Id == reportId && p.OrganizationId == organizationId) + .UpdateAsync(p => new Models.OrganizationReport + { + SummaryData = summaryData, + RevisionDate = DateTime.UtcNow + }); + + // Return the updated report + var updatedReport = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .FirstOrDefaultAsync(); + + return Mapper.Map(updatedReport); + } + } + + public async Task GetSummaryDataAsync(Guid reportId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var result = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .Select(p => new OrganizationReportSummaryDataResponse + { + SummaryData = p.SummaryData + }) + .FirstOrDefaultAsync(); + + return result; + } + } + + public async Task> GetSummaryDataByDateRangeAsync( + Guid organizationId, + DateTime startDate, + DateTime endDate) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var results = await dbContext.OrganizationReports + .Where(p => p.OrganizationId == organizationId && + p.CreationDate >= startDate && p.CreationDate <= endDate) + .Select(p => new OrganizationReportSummaryDataResponse + { + SummaryData = p.SummaryData + }) + .ToListAsync(); + + return results; + } + } + + public async Task GetReportDataAsync(Guid reportId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var result = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .Select(p => new OrganizationReportDataResponse + { + ReportData = p.ReportData + }) + .FirstOrDefaultAsync(); + + return result; + } + } + + public async Task UpdateReportDataAsync(Guid organizationId, Guid reportId, string reportData) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + // Update only ReportData and RevisionDate + await dbContext.OrganizationReports + .Where(p => p.Id == reportId && p.OrganizationId == organizationId) + .UpdateAsync(p => new Models.OrganizationReport + { + ReportData = reportData, + RevisionDate = DateTime.UtcNow + }); + + // Return the updated report + var updatedReport = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .FirstOrDefaultAsync(); + + return Mapper.Map(updatedReport); + } + } + + public async Task GetApplicationDataAsync(Guid reportId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var result = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .Select(p => new OrganizationReportApplicationDataResponse + { + ApplicationData = p.ApplicationData + }) + .FirstOrDefaultAsync(); + + return result; + } + } + + public async Task UpdateApplicationDataAsync(Guid organizationId, Guid reportId, string applicationData) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + // Update only ApplicationData and RevisionDate + await dbContext.OrganizationReports + .Where(p => p.Id == reportId && p.OrganizationId == organizationId) + .UpdateAsync(p => new Models.OrganizationReport + { + ApplicationData = applicationData, + RevisionDate = DateTime.UtcNow + }); + + // Return the updated report + var updatedReport = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .FirstOrDefaultAsync(); + + return Mapper.Map(updatedReport); + } + } } diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index aab83ef7a8..7a6507230e 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -1,7 +1,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Repositories; +using Bit.Core.Billing.Organizations.Repositories; using Bit.Core.Billing.Providers.Repositories; -using Bit.Core.Billing.Repositories; using Bit.Core.Dirt.Reports.Repositories; using Bit.Core.Dirt.Repositories; using Bit.Core.Enums; diff --git a/src/Infrastructure.EntityFramework/Models/Collection.cs b/src/Infrastructure.EntityFramework/Models/Collection.cs index 8418c33703..2057b43ac1 100644 --- a/src/Infrastructure.EntityFramework/Models/Collection.cs +++ b/src/Infrastructure.EntityFramework/Models/Collection.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Models/CollectionCipher.cs b/src/Infrastructure.EntityFramework/Models/CollectionCipher.cs index 4058ddc030..f302685d68 100644 --- a/src/Infrastructure.EntityFramework/Models/CollectionCipher.cs +++ b/src/Infrastructure.EntityFramework/Models/CollectionCipher.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.Vault.Models; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Models/CollectionGroup.cs b/src/Infrastructure.EntityFramework/Models/CollectionGroup.cs index 623a5d8084..f86d89fa33 100644 --- a/src/Infrastructure.EntityFramework/Models/CollectionGroup.cs +++ b/src/Infrastructure.EntityFramework/Models/CollectionGroup.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Models/CollectionUser.cs b/src/Infrastructure.EntityFramework/Models/CollectionUser.cs index 308673492b..7779d13912 100644 --- a/src/Infrastructure.EntityFramework/Models/CollectionUser.cs +++ b/src/Infrastructure.EntityFramework/Models/CollectionUser.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Models/Device.cs b/src/Infrastructure.EntityFramework/Models/Device.cs index 1eace238d5..06054293c8 100644 --- a/src/Infrastructure.EntityFramework/Models/Device.cs +++ b/src/Infrastructure.EntityFramework/Models/Device.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Models/Group.cs b/src/Infrastructure.EntityFramework/Models/Group.cs index 7a537cfcf4..77f59615dd 100644 --- a/src/Infrastructure.EntityFramework/Models/Group.cs +++ b/src/Infrastructure.EntityFramework/Models/Group.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Models/GroupUser.cs b/src/Infrastructure.EntityFramework/Models/GroupUser.cs index 4499b20f8a..57b0610708 100644 --- a/src/Infrastructure.EntityFramework/Models/GroupUser.cs +++ b/src/Infrastructure.EntityFramework/Models/GroupUser.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Models/OrganizationApiKey.cs b/src/Infrastructure.EntityFramework/Models/OrganizationApiKey.cs index e13bde5fb3..280bf3c7ed 100644 --- a/src/Infrastructure.EntityFramework/Models/OrganizationApiKey.cs +++ b/src/Infrastructure.EntityFramework/Models/OrganizationApiKey.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Models/OrganizationConnection.cs b/src/Infrastructure.EntityFramework/Models/OrganizationConnection.cs index 5635bbba7e..0acf783ecb 100644 --- a/src/Infrastructure.EntityFramework/Models/OrganizationConnection.cs +++ b/src/Infrastructure.EntityFramework/Models/OrganizationConnection.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Models/OrganizationDomain.cs b/src/Infrastructure.EntityFramework/Models/OrganizationDomain.cs index 0d9ccefca0..0963d2c119 100644 --- a/src/Infrastructure.EntityFramework/Models/OrganizationDomain.cs +++ b/src/Infrastructure.EntityFramework/Models/OrganizationDomain.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Models/OrganizationSponsorship.cs b/src/Infrastructure.EntityFramework/Models/OrganizationSponsorship.cs index 4780346a1f..85504286f6 100644 --- a/src/Infrastructure.EntityFramework/Models/OrganizationSponsorship.cs +++ b/src/Infrastructure.EntityFramework/Models/OrganizationSponsorship.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Models/OrganizationUser.cs b/src/Infrastructure.EntityFramework/Models/OrganizationUser.cs index 805dec49f7..79bb01fc50 100644 --- a/src/Infrastructure.EntityFramework/Models/OrganizationUser.cs +++ b/src/Infrastructure.EntityFramework/Models/OrganizationUser.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Models/Transaction.cs b/src/Infrastructure.EntityFramework/Models/Transaction.cs index c12654343e..2733609fb7 100644 --- a/src/Infrastructure.EntityFramework/Models/Transaction.cs +++ b/src/Infrastructure.EntityFramework/Models/Transaction.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; diff --git a/src/Infrastructure.EntityFramework/Models/User.cs b/src/Infrastructure.EntityFramework/Models/User.cs index 9e33d9edf6..89e6f35739 100644 --- a/src/Infrastructure.EntityFramework/Models/User.cs +++ b/src/Infrastructure.EntityFramework/Models/User.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.Auth.Models; using Bit.Infrastructure.EntityFramework.Vault.Models; diff --git a/src/Infrastructure.EntityFramework/NotificationCenter/Configurations/NotificationStatusEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/NotificationCenter/Configurations/NotificationStatusEntityTypeConfiguration.cs index 1207d839d8..c535cd3e84 100644 --- a/src/Infrastructure.EntityFramework/NotificationCenter/Configurations/NotificationStatusEntityTypeConfiguration.cs +++ b/src/Infrastructure.EntityFramework/NotificationCenter/Configurations/NotificationStatusEntityTypeConfiguration.cs @@ -13,6 +13,12 @@ public class NotificationStatusEntityTypeConfiguration : IEntityTypeConfiguratio .HasKey(ns => new { ns.UserId, ns.NotificationId }) .IsClustered(); + builder + .HasOne(ns => ns.Notification) + .WithMany() + .HasForeignKey(ns => ns.NotificationId) + .OnDelete(DeleteBehavior.Cascade); + builder.ToTable(nameof(NotificationStatus)); } } diff --git a/src/Infrastructure.EntityFramework/NotificationCenter/Models/Notification.cs b/src/Infrastructure.EntityFramework/NotificationCenter/Models/Notification.cs index ec8db45c5a..af8f7ab295 100644 --- a/src/Infrastructure.EntityFramework/NotificationCenter/Models/Notification.cs +++ b/src/Infrastructure.EntityFramework/NotificationCenter/Models/Notification.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Vault.Models; diff --git a/src/Infrastructure.EntityFramework/NotificationCenter/Models/NotificationStatus.cs b/src/Infrastructure.EntityFramework/NotificationCenter/Models/NotificationStatus.cs index 2a2f8c0ef6..d298708311 100644 --- a/src/Infrastructure.EntityFramework/NotificationCenter/Models/NotificationStatus.cs +++ b/src/Infrastructure.EntityFramework/NotificationCenter/Models/NotificationStatus.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.Models; namespace Bit.Infrastructure.EntityFramework.NotificationCenter.Models; diff --git a/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/NotificationRepository.cs b/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/NotificationRepository.cs index 5d1071f26c..213a14a81d 100644 --- a/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/NotificationRepository.cs +++ b/src/Infrastructure.EntityFramework/NotificationCenter/Repositories/NotificationRepository.cs @@ -74,4 +74,53 @@ public class NotificationRepository : Repository> MarkNotificationsAsDeletedByTask(Guid taskId) + { + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + + var notifications = await dbContext.Notifications + .Where(n => n.TaskId == taskId) + .ToListAsync(); + + var notificationIds = notifications.Select(n => n.Id).ToList(); + + var statuses = await dbContext.Set() + .Where(ns => notificationIds.Contains(ns.NotificationId)) + .ToListAsync(); + + var now = DateTime.UtcNow; + + // Update existing statuses and add missing ones + foreach (var notification in notifications) + { + var status = statuses.FirstOrDefault(s => s.NotificationId == notification.Id); + if (status != null) + { + if (status.DeletedDate == null) + { + status.DeletedDate = now; + } + } + else if (notification.UserId.HasValue) + { + dbContext.Set().Add(new NotificationStatus + { + NotificationId = notification.Id, + UserId = (Guid)notification.UserId, + DeletedDate = now + }); + } + } + + await dbContext.SaveChangesAsync(); + + var userIds = notifications + .Select(n => n.UserId) + .Where(u => u.HasValue) + .ToList(); + + return (IEnumerable)userIds; + } } diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs index d0787f7303..39e3ab8019 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Infrastructure.EntityFramework.Repositories.Queries; using Microsoft.EntityFrameworkCore; @@ -47,6 +48,21 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec } } + public async Task> GetManySharedByOrganizationIdAsync(Guid organizationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var data = await (from cc in dbContext.CollectionCiphers + join c in dbContext.Collections + on cc.CollectionId equals c.Id + where c.OrganizationId == organizationId + && c.Type == Core.Enums.CollectionType.SharedCollection + select cc).ToArrayAsync(); + return data; + } + } + public async Task> GetManyByUserIdAsync(Guid userId) { using (var scope = ServiceScopeFactory.CreateScope()) @@ -130,9 +146,11 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - var availableCollections = await (from c in dbContext.Collections - where c.OrganizationId == organizationId - select c).ToListAsync(); + + var availableCollectionIds = await (from c in dbContext.Collections + where c.OrganizationId == organizationId + && c.Type != CollectionType.DefaultUserCollection + select c.Id).ToListAsync(); var currentCollectionCiphers = await (from cc in dbContext.CollectionCiphers where cc.CipherId == cipherId @@ -140,6 +158,8 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec foreach (var requestedCollectionId in collectionIds) { + if (!availableCollectionIds.Contains(requestedCollectionId)) continue; + var requestedCollectionCipher = currentCollectionCiphers .FirstOrDefault(cc => cc.CollectionId == requestedCollectionId); @@ -153,7 +173,7 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec } } - dbContext.RemoveRange(currentCollectionCiphers.Where(cc => !collectionIds.Contains(cc.CollectionId))); + dbContext.RemoveRange(currentCollectionCiphers.Where(cc => availableCollectionIds.Contains(cc.CollectionId) && !collectionIds.Contains(cc.CollectionId))); await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId); await dbContext.SaveChangesAsync(); } diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 73268d75bf..5aa156d1f8 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -1,8 +1,11 @@ using AutoMapper; +using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Repositories.Queries; +using LinqToDB.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -223,6 +226,20 @@ public class CollectionRepository : Repository> GetManySharedCollectionsByOrganizationIdAsync(Guid organizationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = from c in dbContext.Collections + where c.OrganizationId == organizationId && + c.Type == CollectionType.SharedCollection + select c; + var collections = await query.ToArrayAsync(); + return collections; + } + } + public async Task> GetManyByUserIdAsync(Guid userId) { using (var scope = ServiceScopeFactory.CreateScope()) @@ -241,7 +258,8 @@ public class CollectionRepository : Repository new CollectionDetails { @@ -254,6 +272,7 @@ public class CollectionRepository : Repository Convert.ToInt32(c.ReadOnly))), HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), + Type = collectionGroup.Key.Type, }) .ToList(); } @@ -266,7 +285,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.ReadOnly))), HidePasswords = Convert.ToBoolean(collectionGroup.Min(c => Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), + Type = collectionGroup.Key.Type, }).ToListAsync(); } } @@ -305,7 +326,8 @@ public class CollectionRepository : Repository new CollectionAdminDetails { Id = collectionGroup.Key.Id, @@ -319,7 +341,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), - Unmanaged = collectionGroup.Key.Unmanaged + Unmanaged = collectionGroup.Key.Unmanaged, + DefaultUserCollectionEmail = collectionGroup.Key.DefaultUserCollectionEmail }).ToList(); } else @@ -333,7 +356,8 @@ public class CollectionRepository : Repository Convert.ToInt32(c.HidePasswords))), Manage = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Manage))), Assigned = Convert.ToBoolean(collectionGroup.Max(c => Convert.ToInt32(c.Assigned))), - Unmanaged = collectionGroup.Key.Unmanaged + Unmanaged = collectionGroup.Key.Unmanaged, + DefaultUserCollectionEmail = collectionGroup.Key.DefaultUserCollectionEmail }).ToListAsync(); } @@ -696,6 +721,7 @@ public class CollectionRepository : Repository groups) { var existingCollectionGroups = await dbContext.CollectionGroups @@ -767,4 +793,88 @@ public class CollectionRepository : Repository organizationUserIds, string defaultCollectionName) + { + organizationUserIds = organizationUserIds.ToList(); + if (!organizationUserIds.Any()) + { + return; + } + + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var orgUserIdWithDefaultCollection = await GetOrgUserIdsWithDefaultCollectionAsync(dbContext, organizationId); + var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection); + + var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName); + + if (!collectionUsers.Any() || !collections.Any()) + { + return; + } + + await dbContext.BulkCopyAsync(collections); + await dbContext.BulkCopyAsync(collectionUsers); + + await dbContext.SaveChangesAsync(); + } + + private async Task> GetOrgUserIdsWithDefaultCollectionAsync(DatabaseContext dbContext, Guid organizationId) + { + var results = await dbContext.OrganizationUsers + .Where(ou => ou.OrganizationId == organizationId) + .Join( + dbContext.CollectionUsers, + ou => ou.Id, + cu => cu.OrganizationUserId, + (ou, cu) => new { ou, cu } + ) + .Join( + dbContext.Collections, + temp => temp.cu.CollectionId, + c => c.Id, + (temp, c) => new { temp.ou, Collection = c } + ) + .Where(x => x.Collection.Type == CollectionType.DefaultUserCollection) + .Select(x => x.ou.Id) + .ToListAsync(); + + return results.ToHashSet(); + } + + private (List collectionUser, List collection) BuildDefaultCollectionForUsers(Guid organizationId, IEnumerable missingDefaultCollectionUserIds, string defaultCollectionName) + { + var collectionUsers = new List(); + var collections = new List(); + + foreach (var orgUserId in missingDefaultCollectionUserIds) + { + var collectionId = CoreHelpers.GenerateComb(); + + collections.Add(new Collection + { + Id = collectionId, + OrganizationId = organizationId, + Name = defaultCollectionName, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + Type = CollectionType.DefaultUserCollection, + DefaultUserCollectionEmail = null + + }); + + collectionUsers.Add(new CollectionUser + { + CollectionId = collectionId, + OrganizationUserId = orgUserId, + ReadOnly = false, + HidePasswords = false, + Manage = true, + }); + } + + return (collectionUsers, collections); + } } diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs index e7bee0cdfd..0ddf80130e 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs @@ -152,7 +152,7 @@ public class OrganizationDomainRepository : Repository x.LastCheckedDate < DateTime.UtcNow.AddDays(-expirationPeriod)) + .Where(x => x.LastCheckedDate < DateTime.UtcNow.AddDays(-expirationPeriod) && x.VerifiedDate == null) .ToListAsync(); dbContext.OrganizationDomains.RemoveRange(expiredDomains); return await dbContext.SaveChangesAsync() > 0; diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs index 118757dd2d..2b6e61d056 100644 --- a/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/CollectionAdminDetailsQuery.cs @@ -1,4 +1,5 @@ -using Bit.Core.Models.Data; +using Bit.Core.Enums; +using Bit.Core.Models.Data; namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; @@ -59,7 +60,9 @@ public class CollectionAdminDetailsQuery : IQuery if (_organizationId.HasValue) { - baseCollectionQuery = baseCollectionQuery.Where(x => x.c.OrganizationId == _organizationId); + baseCollectionQuery = baseCollectionQuery.Where(x => + x.c.OrganizationId == _organizationId && + x.c.Type != CollectionType.DefaultUserCollection); } else if (_collectionId.HasValue) { @@ -78,6 +81,7 @@ public class CollectionAdminDetailsQuery : IQuery ExternalId = x.c.ExternalId, CreationDate = x.c.CreationDate, RevisionDate = x.c.RevisionDate, + DefaultUserCollectionEmail = x.c.DefaultUserCollectionEmail, ReadOnly = (bool?)x.cu.ReadOnly ?? (bool?)x.cg.ReadOnly ?? false, HidePasswords = (bool?)x.cu.HidePasswords ?? (bool?)x.cg.HidePasswords ?? false, Manage = (bool?)x.cu.Manage ?? (bool?)x.cg.Manage ?? false, diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs index 507849f51b..b196a07e9b 100644 --- a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Enums; using Bit.Core.Vault.Models.Data; using Bit.Infrastructure.EntityFramework.Vault.Models; @@ -68,7 +71,8 @@ public class UserCipherDetailsQuery : IQuery Manage = cu == null ? (cg != null && cg.Manage == true) : cu.Manage == true, OrganizationUseTotp = o.UseTotp, c.Reprompt, - c.Key + c.Key, + c.ArchivedDate }; var query2 = from c in dbContext.Ciphers @@ -91,7 +95,8 @@ public class UserCipherDetailsQuery : IQuery Manage = true, OrganizationUseTotp = false, c.Reprompt, - c.Key + c.Key, + c.ArchivedDate }; var union = query.Union(query2).Select(c => new CipherDetails @@ -112,7 +117,8 @@ public class UserCipherDetailsQuery : IQuery ViewPassword = c.ViewPassword, Manage = c.Manage, OrganizationUseTotp = c.OrganizationUseTotp, - Key = c.Key + Key = c.Key, + ArchivedDate = c.ArchivedDate }); return union; } diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCollectionDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCollectionDetailsQuery.cs index 6e513e8098..14dd8c876c 100644 --- a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCollectionDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCollectionDetailsQuery.cs @@ -47,17 +47,18 @@ public class UserCollectionDetailsQuery : IQuery ((cu == null ? (Guid?)null : cu.CollectionId) != null || (cg == null ? (Guid?)null : cg.CollectionId) != null) select new { c, ou, o, cu, gu, g, cg }; - return query.Select(x => new CollectionDetails + return query.Select(row => new CollectionDetails { - Id = x.c.Id, - OrganizationId = x.c.OrganizationId, - Name = x.c.Name, - ExternalId = x.c.ExternalId, - CreationDate = x.c.CreationDate, - RevisionDate = x.c.RevisionDate, - ReadOnly = (bool?)x.cu.ReadOnly ?? (bool?)x.cg.ReadOnly ?? false, - HidePasswords = (bool?)x.cu.HidePasswords ?? (bool?)x.cg.HidePasswords ?? false, - Manage = (bool?)x.cu.Manage ?? (bool?)x.cg.Manage ?? false, + Id = row.c.Id, + OrganizationId = row.c.OrganizationId, + Name = row.c.Name, + ExternalId = row.c.ExternalId, + CreationDate = row.c.CreationDate, + RevisionDate = row.c.RevisionDate, + ReadOnly = (bool?)row.cu.ReadOnly ?? (bool?)row.cg.ReadOnly ?? false, + HidePasswords = (bool?)row.cu.HidePasswords ?? (bool?)row.cg.HidePasswords ?? false, + Manage = (bool?)row.cu.Manage ?? (bool?)row.cg.Manage ?? false, + Type = row.c.Type }); } } diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index bd70e27e78..809704edb7 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -283,6 +283,9 @@ public class UserRepository : Repository, IUserR var transaction = await dbContext.Database.BeginTransactionAsync(); + MigrateDefaultUserCollectionsToShared(dbContext, [user.Id]); + await dbContext.SaveChangesAsync(); + dbContext.WebAuthnCredentials.RemoveRange(dbContext.WebAuthnCredentials.Where(w => w.UserId == user.Id)); dbContext.Ciphers.RemoveRange(dbContext.Ciphers.Where(c => c.UserId == user.Id)); dbContext.Folders.RemoveRange(dbContext.Folders.Where(f => f.UserId == user.Id)); @@ -314,8 +317,8 @@ public class UserRepository : Repository, IUserR var mappedUser = Mapper.Map(user); dbContext.Users.Remove(mappedUser); - await transaction.CommitAsync(); await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); } } @@ -329,21 +332,30 @@ public class UserRepository : Repository, IUserR var targetIds = users.Select(u => u.Id).ToList(); + MigrateDefaultUserCollectionsToShared(dbContext, targetIds); + await dbContext.SaveChangesAsync(); + await dbContext.WebAuthnCredentials.Where(wa => targetIds.Contains(wa.UserId)).ExecuteDeleteAsync(); await dbContext.Ciphers.Where(c => targetIds.Contains(c.UserId ?? default)).ExecuteDeleteAsync(); await dbContext.Folders.Where(f => targetIds.Contains(f.UserId)).ExecuteDeleteAsync(); await dbContext.AuthRequests.Where(a => targetIds.Contains(a.UserId)).ExecuteDeleteAsync(); await dbContext.Devices.Where(d => targetIds.Contains(d.UserId)).ExecuteDeleteAsync(); - var collectionUsers = from cu in dbContext.CollectionUsers - join ou in dbContext.OrganizationUsers on cu.OrganizationUserId equals ou.Id - where targetIds.Contains(ou.UserId ?? default) - select cu; - dbContext.CollectionUsers.RemoveRange(collectionUsers); - var groupUsers = from gu in dbContext.GroupUsers - join ou in dbContext.OrganizationUsers on gu.OrganizationUserId equals ou.Id - where targetIds.Contains(ou.UserId ?? default) - select gu; - dbContext.GroupUsers.RemoveRange(groupUsers); + await dbContext.CollectionUsers + .Join(dbContext.OrganizationUsers, + cu => cu.OrganizationUserId, + ou => ou.Id, + (cu, ou) => new { CollectionUser = cu, OrganizationUser = ou }) + .Where((joined) => targetIds.Contains(joined.OrganizationUser.UserId ?? default)) + .Select(joined => joined.CollectionUser) + .ExecuteDeleteAsync(); + await dbContext.GroupUsers + .Join(dbContext.OrganizationUsers, + gu => gu.OrganizationUserId, + ou => ou.Id, + (gu, ou) => new { GroupUser = gu, OrganizationUser = ou }) + .Where(joined => targetIds.Contains(joined.OrganizationUser.UserId ?? default)) + .Select(joined => joined.GroupUser) + .ExecuteDeleteAsync(); await dbContext.UserProjectAccessPolicy.Where(ap => targetIds.Contains(ap.OrganizationUser.UserId ?? default)).ExecuteDeleteAsync(); await dbContext.UserServiceAccountAccessPolicy.Where(ap => targetIds.Contains(ap.OrganizationUser.UserId ?? default)).ExecuteDeleteAsync(); await dbContext.OrganizationUsers.Where(ou => targetIds.Contains(ou.UserId ?? default)).ExecuteDeleteAsync(); @@ -354,15 +366,29 @@ public class UserRepository : Repository, IUserR await dbContext.NotificationStatuses.Where(ns => targetIds.Contains(ns.UserId)).ExecuteDeleteAsync(); await dbContext.Notifications.Where(n => targetIds.Contains(n.UserId ?? default)).ExecuteDeleteAsync(); - foreach (var u in users) - { - var mappedUser = Mapper.Map(u); - dbContext.Users.Remove(mappedUser); - } + await dbContext.Users.Where(u => targetIds.Contains(u.Id)).ExecuteDeleteAsync(); - - await transaction.CommitAsync(); await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + } + } + + private static void MigrateDefaultUserCollectionsToShared(DatabaseContext dbContext, IEnumerable userIds) + { + var defaultCollections = (from c in dbContext.Collections + join cu in dbContext.CollectionUsers on c.Id equals cu.CollectionId + join ou in dbContext.OrganizationUsers on cu.OrganizationUserId equals ou.Id + join u in dbContext.Users on ou.UserId equals u.Id + where userIds.Contains(ou.UserId!.Value) + && c.Type == Core.Enums.CollectionType.DefaultUserCollection + select new { Collection = c, UserEmail = u.Email }) + .ToList(); + + foreach (var item in defaultCollections) + { + item.Collection.Type = Core.Enums.CollectionType.SharedCollection; + item.Collection.DefaultUserCollectionEmail = item.Collection.DefaultUserCollectionEmail ?? item.UserEmail; + item.Collection.RevisionDate = DateTime.UtcNow; } } } diff --git a/src/Infrastructure.EntityFramework/SecretsManager/Models/AccessPolicy.cs b/src/Infrastructure.EntityFramework/SecretsManager/Models/AccessPolicy.cs index 9eca8e5729..769746c27f 100644 --- a/src/Infrastructure.EntityFramework/SecretsManager/Models/AccessPolicy.cs +++ b/src/Infrastructure.EntityFramework/SecretsManager/Models/AccessPolicy.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.Models; namespace Bit.Infrastructure.EntityFramework.SecretsManager.Models; diff --git a/src/Infrastructure.EntityFramework/SecretsManager/Models/ApiKey.cs b/src/Infrastructure.EntityFramework/SecretsManager/Models/ApiKey.cs index 7d2c05f147..9ec45486a0 100644 --- a/src/Infrastructure.EntityFramework/SecretsManager/Models/ApiKey.cs +++ b/src/Infrastructure.EntityFramework/SecretsManager/Models/ApiKey.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; namespace Bit.Infrastructure.EntityFramework.SecretsManager.Models; diff --git a/src/Infrastructure.EntityFramework/SecretsManager/Models/Project.cs b/src/Infrastructure.EntityFramework/SecretsManager/Models/Project.cs index 77ca602841..05035b2f37 100644 --- a/src/Infrastructure.EntityFramework/SecretsManager/Models/Project.cs +++ b/src/Infrastructure.EntityFramework/SecretsManager/Models/Project.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.SecretsManager.Models; diff --git a/src/Infrastructure.EntityFramework/SecretsManager/Models/Secret.cs b/src/Infrastructure.EntityFramework/SecretsManager/Models/Secret.cs index 58dcfce41f..5992f32135 100644 --- a/src/Infrastructure.EntityFramework/SecretsManager/Models/Secret.cs +++ b/src/Infrastructure.EntityFramework/SecretsManager/Models/Secret.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.SecretsManager.Models; diff --git a/src/Infrastructure.EntityFramework/SecretsManager/Models/ServiceAccount.cs b/src/Infrastructure.EntityFramework/SecretsManager/Models/ServiceAccount.cs index 812740e7ae..fa42c16bf3 100644 --- a/src/Infrastructure.EntityFramework/SecretsManager/Models/ServiceAccount.cs +++ b/src/Infrastructure.EntityFramework/SecretsManager/Models/ServiceAccount.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.SecretsManager.Models; diff --git a/src/Infrastructure.EntityFramework/Tools/Models/Send.cs b/src/Infrastructure.EntityFramework/Tools/Models/Send.cs index 1d9c8ae181..0eb4ab39a2 100644 --- a/src/Infrastructure.EntityFramework/Tools/Models/Send.cs +++ b/src/Infrastructure.EntityFramework/Tools/Models/Send.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Vault/Models/Cipher.cs b/src/Infrastructure.EntityFramework/Vault/Models/Cipher.cs index 6655f98912..1cd2ce62fe 100644 --- a/src/Infrastructure.EntityFramework/Vault/Models/Cipher.cs +++ b/src/Infrastructure.EntityFramework/Vault/Models/Cipher.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; using Bit.Infrastructure.EntityFramework.Models; diff --git a/src/Infrastructure.EntityFramework/Vault/Models/Folder.cs b/src/Infrastructure.EntityFramework/Vault/Models/Folder.cs index e27161384e..3485cf19a3 100644 --- a/src/Infrastructure.EntityFramework/Vault/Models/Folder.cs +++ b/src/Infrastructure.EntityFramework/Vault/Models/Folder.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.Models; namespace Bit.Infrastructure.EntityFramework.Vault.Models; diff --git a/src/Infrastructure.EntityFramework/Vault/Models/SecurityTask.cs b/src/Infrastructure.EntityFramework/Vault/Models/SecurityTask.cs index 828c3bbc7d..2dbf37d0cd 100644 --- a/src/Infrastructure.EntityFramework/Vault/Models/SecurityTask.cs +++ b/src/Infrastructure.EntityFramework/Vault/Models/SecurityTask.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Infrastructure.EntityFramework.AdminConsole.Models; namespace Bit.Infrastructure.EntityFramework.Vault.Models; diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index a560e8e107..3c45afe530 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -1,6 +1,10 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using System.Text.Json.Nodes; using AutoMapper; +using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Utilities; using Bit.Core.Vault.Enums; @@ -204,7 +208,7 @@ public class CipherRepository : Repository ids, Guid userId) { - await ToggleCipherStates(ids, userId, CipherStateAction.HardDelete); + await ToggleDeleteCipherStatesAsync(ids, userId, CipherStateAction.HardDelete); } public async Task DeleteAttachmentAsync(Guid cipherId, string attachmentId) @@ -255,17 +259,20 @@ public class CipherRepository : Repository + cc.Collection.Type == CollectionType.DefaultUserCollection) + select c; + dbContext.RemoveRange(ciphersToDelete); - var ciphers = from c in dbContext.Ciphers - where c.OrganizationId == organizationId - select c; - dbContext.RemoveRange(ciphers); + var collectionCiphersToRemove = from cc in dbContext.CollectionCiphers + join col in dbContext.Collections on cc.CollectionId equals col.Id + join c in dbContext.Ciphers on cc.CipherId equals c.Id + where col.Type != CollectionType.DefaultUserCollection + && c.OrganizationId == organizationId + select cc; + dbContext.RemoveRange(collectionCiphersToRemove); await OrganizationUpdateStorage(organizationId); await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId); @@ -482,7 +489,8 @@ public class CipherRepository : Repository(cipher.UserId.Value.ToString(), true), }); - cipher.Favorites = JsonSerializer.Serialize(jsonObject); + cipher.Favorites = JsonSerializer.Serialize(jsonObject); + } + else + { + var favorites = CoreHelpers.LoadClassFromJsonData>(cipher.Favorites); + favorites.Add(cipher.UserId.Value, true); + cipher.Favorites = JsonSerializer.Serialize(favorites); + } } else { - var favorites = CoreHelpers.LoadClassFromJsonData>(cipher.Favorites); - favorites.Add(cipher.UserId.Value, true); - cipher.Favorites = JsonSerializer.Serialize(favorites); - } - } - else - { - if (cipher.Favorites != null && cipher.Favorites.Contains(cipher.UserId.Value.ToString())) - { - var favorites = CoreHelpers.LoadClassFromJsonData>(cipher.Favorites); - favorites.Remove(cipher.UserId.Value); - cipher.Favorites = JsonSerializer.Serialize(favorites); - } - } - if (cipher.FolderId.HasValue) - { - if (cipher.Folders == null) - { - var jsonObject = new JsonObject(new[] + if (cipher.Favorites != null && cipher.Favorites.Contains(cipher.UserId.Value.ToString())) { + var favorites = CoreHelpers.LoadClassFromJsonData>(cipher.Favorites); + favorites.Remove(cipher.UserId.Value); + cipher.Favorites = JsonSerializer.Serialize(favorites); + } + } + if (cipher.FolderId.HasValue) + { + if (cipher.Folders == null) + { + var jsonObject = new JsonObject(new[] + { new KeyValuePair(cipher.UserId.Value.ToString(), cipher.FolderId), }); - cipher.Folders = JsonSerializer.Serialize(jsonObject); + cipher.Folders = JsonSerializer.Serialize(jsonObject); + } + else + { + var folders = CoreHelpers.LoadClassFromJsonData>(cipher.Folders); + folders.Add(cipher.UserId.Value, cipher.FolderId.Value); + cipher.Folders = JsonSerializer.Serialize(folders); + } } else { - var folders = CoreHelpers.LoadClassFromJsonData>(cipher.Folders); - folders.Add(cipher.UserId.Value, cipher.FolderId.Value); - cipher.Folders = JsonSerializer.Serialize(folders); + if (cipher.Folders != null && cipher.Folders.Contains(cipher.UserId.Value.ToString())) + { + var folders = CoreHelpers.LoadClassFromJsonData>(cipher.Folders); + folders.Remove(cipher.UserId.Value); + cipher.Folders = JsonSerializer.Serialize(folders); + } } } - else - { - if (cipher.Folders != null && cipher.Folders.Contains(cipher.UserId.Value.ToString())) - { - var folders = CoreHelpers.LoadClassFromJsonData>(cipher.Folders); - folders.Remove(cipher.UserId.Value); - cipher.Folders = JsonSerializer.Serialize(folders); - } - } - // Check if this cipher is a part of an organization, and if so do // not save the UserId into the database. This must be done after we // set the user specific data like Folders and Favorites because @@ -723,9 +733,14 @@ public class CipherRepository : Repository UnarchiveAsync(IEnumerable ids, Guid userId) + { + return await ToggleArchiveCipherStatesAsync(ids, userId, CipherStateAction.Unarchive); + } + public async Task RestoreAsync(IEnumerable ids, Guid userId) { - return await ToggleCipherStates(ids, userId, CipherStateAction.Restore); + return await ToggleDeleteCipherStatesAsync(ids, userId, CipherStateAction.Restore); } public async Task RestoreByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId) @@ -753,20 +768,25 @@ public class CipherRepository : Repository ids, Guid userId) + public async Task ArchiveAsync(IEnumerable ids, Guid userId) { - await ToggleCipherStates(ids, userId, CipherStateAction.SoftDelete); + return await ToggleArchiveCipherStatesAsync(ids, userId, CipherStateAction.Archive); } - private async Task ToggleCipherStates(IEnumerable ids, Guid userId, CipherStateAction action) + public async Task SoftDeleteAsync(IEnumerable ids, Guid userId) { - static bool FilterDeletedDate(CipherStateAction action, CipherDetails ucd) + await ToggleDeleteCipherStatesAsync(ids, userId, CipherStateAction.SoftDelete); + } + + private async Task ToggleArchiveCipherStatesAsync(IEnumerable ids, Guid userId, CipherStateAction action) + { + static bool FilterArchivedDate(CipherStateAction action, CipherDetails ucd) { return action switch { - CipherStateAction.Restore => ucd.DeletedDate != null, - CipherStateAction.SoftDelete => ucd.DeletedDate == null, - _ => true, + CipherStateAction.Unarchive => ucd.ArchivedDate != null, + CipherStateAction.Archive => ucd.ArchivedDate == null, + _ => true }; } @@ -774,8 +794,49 @@ public class CipherRepository : Repository ids.Contains(c.Id))).ToListAsync(); - var query = from ucd in await (userCipherDetailsQuery.Run(dbContext)).ToListAsync() + var cipherEntitiesToCheck = await dbContext.Ciphers.Where(c => ids.Contains(c.Id)).ToListAsync(); + var query = from ucd in await userCipherDetailsQuery.Run(dbContext).ToListAsync() + join c in cipherEntitiesToCheck + on ucd.Id equals c.Id + where ucd.Edit && FilterArchivedDate(action, ucd) + select c; + + var utcNow = DateTime.UtcNow; + var cipherIdsToModify = query.Select(c => c.Id); + var cipherEntitiesToModify = dbContext.Ciphers.Where(x => cipherIdsToModify.Contains(x.Id)); + + await cipherEntitiesToModify.ForEachAsync(cipher => + { + dbContext.Attach(cipher); + cipher.ArchivedDate = action == CipherStateAction.Unarchive ? null : utcNow; + cipher.RevisionDate = utcNow; + }); + + await dbContext.UserBumpAccountRevisionDateAsync(userId); + await dbContext.SaveChangesAsync(); + + return utcNow; + } + } + + private async Task ToggleDeleteCipherStatesAsync(IEnumerable ids, Guid userId, CipherStateAction action) + { + static bool FilterDeletedDate(CipherStateAction action, CipherDetails ucd) + { + return action switch + { + CipherStateAction.Restore => ucd.DeletedDate != null, + CipherStateAction.SoftDelete => ucd.DeletedDate == null, + _ => true + }; + } + + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var userCipherDetailsQuery = new UserCipherDetailsQuery(userId); + var cipherEntitiesToCheck = await dbContext.Ciphers.Where(c => ids.Contains(c.Id)).ToListAsync(); + var query = from ucd in await userCipherDetailsQuery.Run(dbContext).ToListAsync() join c in cipherEntitiesToCheck on ucd.Id equals c.Id where ucd.Edit && FilterDeletedDate(action, ucd) @@ -813,6 +874,7 @@ public class CipherRepository : Repository> + GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(Guid organizationId) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var defaultTypeInt = (int)CollectionType.DefaultUserCollection; + + // filter out any cipher that belongs *only* to default collections + // i.e. keep ciphers with no collections, or with ≥1 non-default collection + var query = from c in dbContext.Ciphers.AsNoTracking() + where c.UserId == null + && c.OrganizationId == organizationId + && c.Organization.Enabled + && ( + c.CollectionCiphers.Count() == 0 + || c.CollectionCiphers.Any(cc => (int)cc.Collection.Type != defaultTypeInt) + ) + select new CipherOrganizationDetailsWithCollections( + new CipherOrganizationDetails + { + Id = c.Id, + UserId = c.UserId, + OrganizationId = c.OrganizationId, + Type = c.Type, + Data = c.Data, + Favorites = c.Favorites, + Folders = c.Folders, + Attachments = c.Attachments, + CreationDate = c.CreationDate, + RevisionDate = c.RevisionDate, + DeletedDate = c.DeletedDate, + Reprompt = c.Reprompt, + Key = c.Key, + OrganizationUseTotp = c.Organization.UseTotp + }, + new Dictionary>() + ) + { + CollectionIds = c.CollectionCiphers + .Where(cc => (int)cc.Collection.Type != defaultTypeInt) + .Select(cc => cc.CollectionId) + .ToArray() + }; + + var result = await query.ToListAsync(); + return result; + } + public async Task UpsertAsync(CipherDetails cipher) { if (cipher.Id.Equals(default)) diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/FolderRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/FolderRepository.cs index 09ac256332..83fa442eb4 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/FolderRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/FolderRepository.cs @@ -1,4 +1,7 @@ -using AutoMapper; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AutoMapper; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Vault.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs index b9de76d3ff..880ee77854 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs @@ -34,6 +34,7 @@ public class CipherDetailsQuery : IQuery FolderId = (_ignoreFolders || !_userId.HasValue || c.Folders == null || !c.Folders.ToLowerInvariant().Contains(_userId.Value.ToString())) ? null : CoreHelpers.LoadClassFromJsonData>(c.Folders)[_userId.Value], + ArchivedDate = c.ArchivedDate, }; return query; } diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/UserSecurityTasksByCipherIdsQuery.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/UserSecurityTasksByCipherIdsQuery.cs index c36c0d87c4..1038c208c2 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/UserSecurityTasksByCipherIdsQuery.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/UserSecurityTasksByCipherIdsQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.Vault.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Vault.Models.Data; using Bit.Infrastructure.EntityFramework.Repositories; using Bit.Infrastructure.EntityFramework.Repositories.Queries; diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs index a3ba2632fe..d4f9424d40 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs @@ -76,4 +76,24 @@ public class SecurityTaskRepository : Repository + public async Task GetTaskMetricsAsync(Guid organizationId) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var metrics = await (from st in dbContext.SecurityTasks + join o in dbContext.Organizations on st.OrganizationId equals o.Id + where st.OrganizationId == organizationId && o.Enabled + select st) + .GroupBy(x => 1) + .Select(g => new Core.Vault.Entities.SecurityTaskMetrics( + g.Count(x => x.Status == SecurityTaskStatus.Completed), + g.Count() + )) + .FirstOrDefaultAsync(); + + return metrics ?? new Core.Vault.Entities.SecurityTaskMetrics(0, 0); + } } diff --git a/src/Notifications/AnonymousNotificationsHub.cs b/src/Notifications/AnonymousNotificationsHub.cs index e3e7d478c8..ae17de1af3 100644 --- a/src/Notifications/AnonymousNotificationsHub.cs +++ b/src/Notifications/AnonymousNotificationsHub.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Authorization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; namespace Bit.Notifications; diff --git a/src/Notifications/AzureQueueHostedService.cs b/src/Notifications/AzureQueueHostedService.cs index 977d9a9d1d..94aa14eaf6 100644 --- a/src/Notifications/AzureQueueHostedService.cs +++ b/src/Notifications/AzureQueueHostedService.cs @@ -1,4 +1,7 @@ -using Azure.Storage.Queues; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Azure.Storage.Queues; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.SignalR; @@ -84,6 +87,11 @@ public class AzureQueueHostedService : IHostedService, IDisposable await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); } } + catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug("Task.Delay cancelled during Alpine container shutdown"); + break; + } catch (Exception e) { _logger.LogError(e, "Error processing messages."); diff --git a/src/Notifications/Dockerfile b/src/Notifications/Dockerfile index 9cbc10e664..031df0b1b6 100644 --- a/src/Notifications/Dockerfile +++ b/src/Notifications/Dockerfile @@ -1,7 +1,7 @@ ############################################### # Build stage # ############################################### -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build # Docker buildx supplies the value for this arg ARG TARGETPLATFORM @@ -9,11 +9,11 @@ ARG TARGETPLATFORM # Determine proper runtime value for .NET # We put the value in a file to be read by later layers. RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ - RID=linux-x64 ; \ + RID=linux-musl-x64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ - RID=linux-arm64 ; \ + RID=linux-musl-arm64 ; \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ - RID=linux-arm ; \ + RID=linux-musl-arm ; \ fi \ && echo "RID=$RID" > /tmp/rid.txt @@ -37,25 +37,28 @@ RUN . /tmp/rid.txt && dotnet publish \ ############################################### # App stage # ############################################### -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_URLS=http://+:5000 ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache curl \ + icu-libs \ + shadow \ + tzdata \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu # Copy app from the build stage WORKDIR /app COPY --from=build /source/src/Notifications/out /app COPY ./src/Notifications/entrypoint.sh /entrypoint.sh +RUN echo "net.ipv4.ip_local_port_range = 5024 65000" >> /etc/sysctl.d/99-sysctl.conf +RUN echo "net.ipv4.tcp_fin_timeout = 30" >> /etc/sysctl.d/99-sysctl.conf RUN chmod +x /entrypoint.sh HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 diff --git a/src/Notifications/HeartbeatHostedService.cs b/src/Notifications/HeartbeatHostedService.cs index 6dcfe7189f..e69cab3e78 100644 --- a/src/Notifications/HeartbeatHostedService.cs +++ b/src/Notifications/HeartbeatHostedService.cs @@ -1,4 +1,7 @@ -using Bit.Core.Settings; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Settings; using Microsoft.AspNetCore.SignalR; namespace Bit.Notifications; diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index 441842da3b..69d5bdc958 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Enums; using Bit.Core.Models; using Microsoft.AspNetCore.SignalR; @@ -103,6 +106,20 @@ public static class HubHelpers await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationCollectionSettingsChangedNotification.Payload.OrganizationId)) .SendAsync(_receiveMessageMethod, organizationCollectionSettingsChangedNotification, cancellationToken); break; + case PushType.OrganizationBankAccountVerified: + var organizationBankAccountVerifiedNotification = + JsonSerializer.Deserialize>( + notificationJson, _deserializerOptions); + await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationBankAccountVerifiedNotification.Payload.OrganizationId)) + .SendAsync(_receiveMessageMethod, organizationBankAccountVerifiedNotification, cancellationToken); + break; + case PushType.ProviderBankAccountVerified: + var providerBankAccountVerifiedNotification = + JsonSerializer.Deserialize>( + notificationJson, _deserializerOptions); + await hubContext.Clients.User(providerBankAccountVerifiedNotification.Payload.AdminId.ToString()) + .SendAsync(_receiveMessageMethod, providerBankAccountVerifiedNotification, cancellationToken); + break; case PushType.Notification: case PushType.NotificationStatus: var notificationData = JsonSerializer.Deserialize>( @@ -135,12 +152,13 @@ public static class HubHelpers } break; - case PushType.PendingSecurityTasks: + case PushType.RefreshSecurityTasks: var pendingTasksData = JsonSerializer.Deserialize>(notificationJson, _deserializerOptions); await hubContext.Clients.User(pendingTasksData.Payload.UserId.ToString()) .SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken); break; default: + logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type); break; } } diff --git a/src/Notifications/NotificationsHub.cs b/src/Notifications/NotificationsHub.cs index ed62dbbd66..bc123fcf84 100644 --- a/src/Notifications/NotificationsHub.cs +++ b/src/Notifications/NotificationsHub.cs @@ -1,4 +1,7 @@ -using Bit.Core.Context; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Settings; using Bit.Core.Utilities; diff --git a/src/Notifications/Startup.cs b/src/Notifications/Startup.cs index 440808b78b..eb3c3f8682 100644 --- a/src/Notifications/Startup.cs +++ b/src/Notifications/Startup.cs @@ -1,9 +1,9 @@ using System.Globalization; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; -using IdentityModel; +using Duende.IdentityModel; using Microsoft.AspNetCore.SignalR; using Microsoft.IdentityModel.Logging; diff --git a/src/Notifications/SubjectUserIdProvider.cs b/src/Notifications/SubjectUserIdProvider.cs index 261394d06c..50d3d1966e 100644 --- a/src/Notifications/SubjectUserIdProvider.cs +++ b/src/Notifications/SubjectUserIdProvider.cs @@ -1,4 +1,7 @@ -using IdentityModel; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Duende.IdentityModel; using Microsoft.AspNetCore.SignalR; namespace Bit.Notifications; diff --git a/src/Notifications/entrypoint.sh b/src/Notifications/entrypoint.sh index d95324de2f..4c5759675b 100644 --- a/src/Notifications/entrypoint.sh +++ b/src/Notifications/entrypoint.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # Setup diff --git a/src/SharedWeb/Health/HealthCheckServiceExtensions.cs b/src/SharedWeb/Health/HealthCheckServiceExtensions.cs index 9be369c676..4fa8d71ca0 100644 --- a/src/SharedWeb/Health/HealthCheckServiceExtensions.cs +++ b/src/SharedWeb/Health/HealthCheckServiceExtensions.cs @@ -1,4 +1,7 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text; using System.Text.Json; using Bit.Core.Settings; using Microsoft.AspNetCore.Http; diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj index 1951e4d509..8bffa285fc 100644 --- a/src/SharedWeb/SharedWeb.csproj +++ b/src/SharedWeb/SharedWeb.csproj @@ -7,7 +7,7 @@
- +
diff --git a/src/SharedWeb/Swagger/ActionNameOperationFilter.cs b/src/SharedWeb/Swagger/ActionNameOperationFilter.cs new file mode 100644 index 0000000000..b76e8864ba --- /dev/null +++ b/src/SharedWeb/Swagger/ActionNameOperationFilter.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +/// +/// Adds the action name (function name) as an extension to each operation in the Swagger document. +/// This can be useful for the code generation process, to generate more meaningful names for operations. +/// Note that we add both the original action name and a snake_case version, as the codegen templates +/// cannot do case conversions. +/// +public class ActionNameOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (!context.ApiDescription.ActionDescriptor.RouteValues.TryGetValue("action", out var action)) return; + if (string.IsNullOrEmpty(action)) return; + + operation.Extensions.Add("x-action-name", new OpenApiString(action)); + // We can't do case changes in the codegen templates, so we also add the snake_case version of the action name + operation.Extensions.Add("x-action-name-snake-case", new OpenApiString(JsonNamingPolicy.SnakeCaseLower.ConvertName(action))); + } +} diff --git a/src/SharedWeb/Swagger/CheckDuplicateOperationIdsDocumentFilter.cs b/src/SharedWeb/Swagger/CheckDuplicateOperationIdsDocumentFilter.cs new file mode 100644 index 0000000000..3079a9171a --- /dev/null +++ b/src/SharedWeb/Swagger/CheckDuplicateOperationIdsDocumentFilter.cs @@ -0,0 +1,80 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +/// +/// Checks for duplicate operation IDs in the Swagger document, and throws an error if any are found. +/// Operation IDs must be unique across the entire Swagger document according to the OpenAPI specification, +/// but we use controller action names to generate them, which can lead to duplicates if a Controller function +/// has multiple HTTP methods or if a Controller has overloaded functions. +/// +public class CheckDuplicateOperationIdsDocumentFilter(bool printDuplicates = true) : IDocumentFilter +{ + public bool PrintDuplicates { get; } = printDuplicates; + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + var operationIdMap = new Dictionary>(); + + foreach (var (path, pathItem) in swaggerDoc.Paths) + { + foreach (var operation in pathItem.Operations) + { + if (!operationIdMap.TryGetValue(operation.Value.OperationId, out var list)) + { + list = []; + operationIdMap[operation.Value.OperationId] = list; + } + + list.Add((path, pathItem, operation.Key, operation.Value)); + + } + } + + // Find duplicates + var duplicates = operationIdMap.Where((kvp) => kvp.Value.Count > 1).ToList(); + if (duplicates.Count > 0) + { + if (PrintDuplicates) + { + Console.WriteLine($"\n######## Duplicate operationIds found in the schema ({duplicates.Count} found) ########\n"); + + Console.WriteLine("## Common causes of duplicate operation IDs:"); + Console.WriteLine("- Multiple HTTP methods (GET, POST, etc.) on the same controller function"); + Console.WriteLine(" Solution: Split the methods into separate functions, and if appropiate, mark the deprecated ones with [Obsolete]"); + Console.WriteLine(); + Console.WriteLine("- Overloaded controller functions with the same name"); + Console.WriteLine(" Solution: Rename the overloaded functions to have unique names, or combine them into a single function with optional parameters"); + Console.WriteLine(); + + Console.WriteLine("## The duplicate operation IDs are:"); + + foreach (var (operationId, duplicate) in duplicates) + { + Console.WriteLine($"- operationId: {operationId}"); + foreach (var (path, pathItem, method, operation) in duplicate) + { + Console.Write($" {method.ToString().ToUpper()} {path}"); + + + if (operation.Extensions.TryGetValue("x-source-file", out var sourceFile) && operation.Extensions.TryGetValue("x-source-line", out var sourceLine)) + { + var sourceFileString = ((Microsoft.OpenApi.Any.OpenApiString)sourceFile).Value; + var sourceLineString = ((Microsoft.OpenApi.Any.OpenApiInteger)sourceLine).Value; + + Console.WriteLine($" {sourceFileString}:{sourceLineString}"); + } + else + { + Console.WriteLine(); + } + } + Console.WriteLine("\n"); + } + } + + throw new InvalidOperationException($"Duplicate operation IDs found in Swagger schema"); + } + } +} diff --git a/src/SharedWeb/Swagger/EncryptedStringSchemaFilter.cs b/src/SharedWeb/Swagger/EncryptedStringSchemaFilter.cs new file mode 100644 index 0000000000..d26ae58e59 --- /dev/null +++ b/src/SharedWeb/Swagger/EncryptedStringSchemaFilter.cs @@ -0,0 +1,40 @@ +#nullable enable + +using System.Text.Json; +using Bit.Core.Utilities; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +/// +/// Set the format of any strings that are decorated with the to "x-enc-string". +/// This will allow the generated bindings to use a more appropriate type for encrypted strings. +/// +public class EncryptedStringSchemaFilter : ISchemaFilter +{ + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + if (context.Type == null || schema.Properties == null) + return; + + foreach (var prop in context.Type.GetProperties()) + { + // Only apply to string properties + if (prop.PropertyType != typeof(string)) + continue; + + // Check if the property has the EncryptedString attribute + if (prop.GetCustomAttributes(typeof(EncryptedStringAttribute), true).FirstOrDefault() != null) + { + // Convert prop.Name to camelCase for JSON schema property lookup + var jsonPropName = JsonNamingPolicy.CamelCase.ConvertName(prop.Name); + + if (schema.Properties.TryGetValue(jsonPropName, out var value)) + { + value.Format = "x-enc-string"; + } + } + } + } +} diff --git a/src/SharedWeb/Swagger/GitCommitDocumentFilter.cs b/src/SharedWeb/Swagger/GitCommitDocumentFilter.cs new file mode 100644 index 0000000000..86678722ce --- /dev/null +++ b/src/SharedWeb/Swagger/GitCommitDocumentFilter.cs @@ -0,0 +1,50 @@ +#nullable enable + +using System.Diagnostics; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +/// +/// Add the Git commit that was used to generate the Swagger document, to help with debugging and reproducibility. +/// +public class GitCommitDocumentFilter : IDocumentFilter +{ + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + if (!string.IsNullOrEmpty(GitCommit)) + { + swaggerDoc.Extensions.Add("x-git-commit", new Microsoft.OpenApi.Any.OpenApiString(GitCommit)); + } + } + + public static string? GitCommit => _gitCommit.Value; + + private static readonly Lazy _gitCommit = new(() => + { + try + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = "rev-parse HEAD", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.Start(); + var result = process.StandardOutput.ReadLine()?.Trim(); + process.WaitForExit(); + return result ?? string.Empty; + } + catch + { + return null; + } + }); +} diff --git a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs new file mode 100644 index 0000000000..68c0b5145a --- /dev/null +++ b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs @@ -0,0 +1,81 @@ +#nullable enable + +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Runtime.CompilerServices; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +/// +/// Adds source file and line number information to the Swagger operation description. +/// This can be useful for locating the source code of the operation in the repository, +/// as the generated names are based on the HTTP path, and are hard to search for. +/// +public class SourceFileLineOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + + var (fileName, lineNumber) = GetSourceFileLine(context.MethodInfo); + if (fileName != null && lineNumber > 0) + { + // Also add the information as extensions, so other tools can use it in the future + operation.Extensions.Add("x-source-file", new OpenApiString(fileName)); + operation.Extensions.Add("x-source-line", new OpenApiInteger(lineNumber)); + } + } + + private static (string? fileName, int lineNumber) GetSourceFileLine(MethodInfo methodInfo) + { + // Get the location of the PDB file associated with the module of the method + var pdbPath = Path.ChangeExtension(methodInfo.Module.FullyQualifiedName, ".pdb"); + if (!File.Exists(pdbPath)) return (null, 0); + + // Open the PDB file and read the metadata + using var pdbStream = File.OpenRead(pdbPath); + using var metadataReaderProvider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); + var metadataReader = metadataReaderProvider.GetMetadataReader(); + + // If the method is async, the compiler will generate a state machine, + // so we can't look for the original method, but we instead need to look + // for the MoveNext method of the state machine. + var attr = methodInfo.GetCustomAttribute(); + if (attr?.StateMachineType != null) + { + var moveNext = attr.StateMachineType.GetMethod("MoveNext", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (moveNext != null) methodInfo = moveNext; + } + + // Once we have the method, we can get its sequence points + var handle = (MethodDefinitionHandle)MetadataTokens.Handle(methodInfo.MetadataToken); + if (handle.IsNil) return (null, 0); + var sequencePoints = metadataReader.GetMethodDebugInformation(handle).GetSequencePoints(); + + // Iterate through the sequence points and pick the first one that has a valid line number + foreach (var sp in sequencePoints) + { + var docName = metadataReader.GetDocument(sp.Document).Name; + if (sp.StartLine != 0 && sp.StartLine != SequencePoint.HiddenLine && !docName.IsNil) + { + var fileName = metadataReader.GetString(docName); + var repoRoot = FindRepoRoot(AppContext.BaseDirectory); + var relativeFileName = repoRoot != null ? Path.GetRelativePath(repoRoot, fileName) : fileName; + return (relativeFileName, sp.StartLine); + } + } + + return (null, 0); + } + + private static string? FindRepoRoot(string startPath) + { + var dir = new DirectoryInfo(startPath); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, ".git"))) + dir = dir.Parent; + return dir?.FullName; + } +} diff --git a/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs b/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs new file mode 100644 index 0000000000..d57ee72d48 --- /dev/null +++ b/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +public static class SwaggerGenOptionsExt +{ + + public static void InitializeSwaggerFilters( + this SwaggerGenOptions config, IWebHostEnvironment environment) + { + config.SchemaFilter(); + config.SchemaFilter(); + + config.OperationFilter(); + + // Set the operation ID to the name of the controller followed by the name of the function. + // Note that the "Controller" suffix for the controllers, and the "Async" suffix for the actions + // are removed already, so we don't need to do that ourselves. + config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); + // Because we're setting custom operation IDs, we need to ensure that we don't accidentally + // introduce duplicate IDs, which is against the OpenAPI specification and could lead to issues. + config.DocumentFilter(); + + // These two filters require debug symbols/git, so only add them in development mode + if (environment.IsDevelopment()) + { + config.DocumentFilter(); + config.OperationFilter(); + } + } +} diff --git a/src/SharedWeb/Utilities/DisplayAttributeHelpers.cs b/src/SharedWeb/Utilities/DisplayAttributeHelpers.cs index 4a48a2d164..33cd5cb91e 100644 --- a/src/SharedWeb/Utilities/DisplayAttributeHelpers.cs +++ b/src/SharedWeb/Utilities/DisplayAttributeHelpers.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Reflection; namespace Bit.SharedWeb.Utilities; diff --git a/src/SharedWeb/Utilities/ExceptionHandlerFilterAttribute.cs b/src/SharedWeb/Utilities/ExceptionHandlerFilterAttribute.cs index f43544bca4..332aa6838c 100644 --- a/src/SharedWeb/Utilities/ExceptionHandlerFilterAttribute.cs +++ b/src/SharedWeb/Utilities/ExceptionHandlerFilterAttribute.cs @@ -1,4 +1,7 @@ -using Bit.Core.Exceptions; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Exceptions; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 1c4473674c..58ce0466c3 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -1,9 +1,14 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using System.Reflection; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; -using Azure.Storage.Queues; +using Azure.Messaging.ServiceBus; +using Bit.Core; +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -20,6 +25,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Auth.Services.Implementations; using Bit.Core.Auth.UserFeatures; +using Bit.Core.Auth.UserFeatures.PasswordValidation; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; using Bit.Core.Billing.TrialInitiation; @@ -27,20 +33,18 @@ using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.HostedServices; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.KeyManagement; using Bit.Core.NotificationCenter; -using Bit.Core.NotificationHub; using Bit.Core.OrganizationFeatures; using Bit.Core.Platform; using Bit.Core.Platform.Push; -using Bit.Core.Platform.Push.Internal; +using Bit.Core.Platform.PushRegistration.Internal; using Bit.Core.Repositories; using Bit.Core.Resources; using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories.Noop; using Bit.Core.Services; +using Bit.Core.Services.Implementations; using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Tools.ImportFeatures; @@ -52,7 +56,7 @@ using Bit.Core.Vault.Services; using Bit.Infrastructure.Dapper; using Bit.Infrastructure.EntityFramework; using DnsClient; -using IdentityModel; +using Duende.IdentityModel; using LaunchDarkly.Sdk.Server; using LaunchDarkly.Sdk.Server.Interfaces; using Microsoft.AspNetCore.Authentication.Cookies; @@ -117,7 +121,6 @@ public static class ServiceCollectionExtensions services.AddTrialInitiationServices(); services.AddOrganizationServices(globalSettings); services.AddPolicyServices(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -235,6 +238,7 @@ public static class ServiceCollectionExtensions }); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -247,14 +251,19 @@ public static class ServiceCollectionExtensions services.AddOptionality(); services.AddTokenizers(); + services.AddSingleton(); + services.AddScoped(); + if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) && CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName)) { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } else { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } var awsConfigured = CoreHelpers.SettingHasValue(globalSettings.Amazon?.AccessKeySecret); @@ -275,46 +284,8 @@ public static class ServiceCollectionExtensions services.AddSingleton(); } - services.TryAddSingleton(TimeProvider.System); - - services.AddSingleton(); - if (globalSettings.SelfHosted) - { - if (globalSettings.Installation.Id == Guid.Empty) - { - throw new InvalidOperationException("Installation Id must be set for self-hosted installations."); - } - - if (CoreHelpers.SettingHasValue(globalSettings.PushRelayBaseUri) && - CoreHelpers.SettingHasValue(globalSettings.Installation.Key)) - { - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.AddSingleton(); - } - else - { - services.AddSingleton(); - } - - if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) && - CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications)) - { - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - } - } - else - { - services.AddSingleton(); - services.AddSingleton(); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddSingleton(); - if (CoreHelpers.SettingHasValue(globalSettings.Notifications?.ConnectionString)) - { - services.AddKeyedSingleton("notifications", - (_, _) => new QueueClient(globalSettings.Notifications.ConnectionString, "notifications")); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - } - } + services.AddPush(globalSettings); + services.AddPushRegistration(); if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Mail.ConnectionString)) { @@ -369,14 +340,15 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); } public static IdentityBuilder AddCustomIdentityServices( this IServiceCollection services, GlobalSettings globalSettings) { + services.TryAddTransient(typeof(IOtpTokenProvider<>), typeof(OtpTokenProvider<>)); + services.AddScoped(); - services.Configure(options => options.IterationCount = 100000); + services.Configure(options => options.IterationCount = PasswordValidationConstants.PasswordHasherKdfIterations); services.Configure(options => { options.TokenLifespan = TimeSpan.FromDays(30); @@ -544,186 +516,57 @@ public static class ServiceCollectionExtensions { if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) { - services.AddKeyedSingleton("storage"); + services.TryAddKeyedSingleton("storage"); if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.EventTopicName)) { - services.AddSingleton(); - services.AddKeyedSingleton("broadcast"); + services.TryAddSingleton(); + services.TryAddKeyedSingleton("broadcast"); } else { - services.AddKeyedSingleton("broadcast"); + services.TryAddKeyedSingleton("broadcast"); } } else if (globalSettings.SelfHosted) { - services.AddKeyedSingleton("storage"); + services.TryAddKeyedSingleton("storage"); if (IsRabbitMqEnabled(globalSettings)) { - services.AddSingleton(); - services.AddKeyedSingleton("broadcast"); + services.TryAddSingleton(); + services.TryAddKeyedSingleton("broadcast"); } else { - services.AddKeyedSingleton("broadcast"); + services.TryAddKeyedSingleton("broadcast"); } } else { - services.AddKeyedSingleton("storage"); - services.AddKeyedSingleton("broadcast"); + services.TryAddKeyedSingleton("storage"); + services.TryAddKeyedSingleton("broadcast"); } - services.AddScoped(); - return services; - } - - private static IServiceCollection AddAzureServiceBusEventRepositoryListener(this IServiceCollection services, GlobalSettings globalSettings) - { - services.AddSingleton(); - services.AddSingleton(); - services.AddKeyedSingleton("persistent"); - services.AddSingleton(provider => - new AzureServiceBusEventListenerService( - handler: provider.GetRequiredService(), - serviceBusService: provider.GetRequiredService(), - subscriptionName: globalSettings.EventLogging.AzureServiceBus.EventRepositorySubscriptionName, - globalSettings: globalSettings, - logger: provider.GetRequiredService>() - ) - ); - - return services; - } - - private static IServiceCollection AddAzureServiceBusIntegration( - this IServiceCollection services, - string eventSubscriptionName, - string integrationSubscriptionName, - IntegrationType integrationType, - GlobalSettings globalSettings) - where TConfig : class - where THandler : class, IIntegrationHandler - { - var routingKey = integrationType.ToRoutingKey(); - - services.AddKeyedSingleton(routingKey, (provider, _) => - new EventIntegrationHandler( - integrationType, - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService>>())); - - services.AddSingleton(provider => - new AzureServiceBusEventListenerService( - handler: provider.GetRequiredKeyedService(routingKey), - serviceBusService: provider.GetRequiredService(), - subscriptionName: eventSubscriptionName, - globalSettings: globalSettings, - logger: provider.GetRequiredService>() - ) - ); - - services.AddSingleton, THandler>(); - services.AddSingleton(provider => - new AzureServiceBusIntegrationListenerService( - handler: provider.GetRequiredService>(), - topicName: globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName, - subscriptionName: integrationSubscriptionName, - maxRetries: globalSettings.EventLogging.AzureServiceBus.MaxRetries, - serviceBusService: provider.GetRequiredService(), - logger: provider.GetRequiredService>())); - + services.TryAddScoped(); return services; } public static IServiceCollection AddAzureServiceBusListeners(this IServiceCollection services, GlobalSettings globalSettings) { - if (!CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) || - !CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.EventTopicName)) + if (!IsAzureServiceBusEnabled(globalSettings)) + { return services; + } - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddAzureServiceBusEventRepositoryListener(globalSettings); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddKeyedSingleton("persistent"); + services.TryAddSingleton(); - services.AddSlackService(globalSettings); - services.AddAzureServiceBusIntegration( - eventSubscriptionName: globalSettings.EventLogging.AzureServiceBus.SlackEventSubscriptionName, - integrationSubscriptionName: globalSettings.EventLogging.AzureServiceBus.SlackIntegrationSubscriptionName, - integrationType: IntegrationType.Slack, - globalSettings: globalSettings); - - services.AddHttpClient(WebhookIntegrationHandler.HttpClientName); - services.AddAzureServiceBusIntegration( - eventSubscriptionName: globalSettings.EventLogging.AzureServiceBus.WebhookEventSubscriptionName, - integrationSubscriptionName: globalSettings.EventLogging.AzureServiceBus.WebhookIntegrationSubscriptionName, - integrationType: IntegrationType.Webhook, - globalSettings: globalSettings); - return services; - } - - private static IServiceCollection AddRabbitMqEventRepositoryListener(this IServiceCollection services, GlobalSettings globalSettings) - { - services.AddSingleton(); - services.AddKeyedSingleton("persistent"); - - services.AddSingleton(provider => - new RabbitMqEventListenerService( - provider.GetRequiredService(), - globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName, - provider.GetRequiredService(), - provider.GetRequiredService>())); - - return services; - } - - private static IServiceCollection AddRabbitMqIntegration(this IServiceCollection services, - string eventQueueName, - string integrationQueueName, - string integrationRetryQueueName, - int maxRetries, - IntegrationType integrationType) - where TConfig : class - where THandler : class, IIntegrationHandler - { - var routingKey = integrationType.ToRoutingKey(); - - services.AddKeyedSingleton(routingKey, (provider, _) => - new EventIntegrationHandler( - integrationType, - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService>>())); - - services.AddSingleton(provider => - new RabbitMqEventListenerService( - provider.GetRequiredKeyedService(routingKey), - eventQueueName, - provider.GetRequiredService(), - provider.GetRequiredService>())); - - services.AddSingleton, THandler>(); - services.AddSingleton(provider => - new RabbitMqIntegrationListenerService( - handler: provider.GetRequiredService>(), - routingKey: routingKey, - queueName: integrationQueueName, - retryQueueName: integrationRetryQueueName, - maxRetries: maxRetries, - rabbitMqService: provider.GetRequiredService(), - logger: provider.GetRequiredService>(), - timeProvider: provider.GetRequiredService())); + services.AddEventIntegrationServices(globalSettings); return services; } @@ -735,38 +578,15 @@ public static class ServiceCollectionExtensions return services; } - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddRabbitMqEventRepositoryListener(globalSettings); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); - services.AddSlackService(globalSettings); - services.AddRabbitMqIntegration( - globalSettings.EventLogging.RabbitMq.SlackEventsQueueName, - globalSettings.EventLogging.RabbitMq.SlackIntegrationQueueName, - globalSettings.EventLogging.RabbitMq.SlackIntegrationRetryQueueName, - globalSettings.EventLogging.RabbitMq.MaxRetries, - IntegrationType.Slack); - - services.AddHttpClient(WebhookIntegrationHandler.HttpClientName); - services.AddRabbitMqIntegration( - globalSettings.EventLogging.RabbitMq.WebhookEventsQueueName, - globalSettings.EventLogging.RabbitMq.WebhookIntegrationQueueName, - globalSettings.EventLogging.RabbitMq.WebhookIntegrationRetryQueueName, - globalSettings.EventLogging.RabbitMq.MaxRetries, - IntegrationType.Webhook); + services.AddEventIntegrationServices(globalSettings); return services; } - private static bool IsRabbitMqEnabled(GlobalSettings settings) - { - return CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.HostName) && - CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Username) && - CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Password) && - CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName); - } - public static IServiceCollection AddSlackService(this IServiceCollection services, GlobalSettings globalSettings) { if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) && @@ -774,11 +594,11 @@ public static class ServiceCollectionExtensions CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes)) { services.AddHttpClient(SlackService.HttpClientName); - services.AddSingleton(); + services.TryAddSingleton(); } else { - services.AddSingleton(); + services.TryAddSingleton(); } return services; @@ -876,8 +696,23 @@ public static class ServiceCollectionExtensions { options.ServerDomain = new Uri(globalSettings.BaseServiceUri.Vault).Host; options.ServerName = "Bitwarden"; - options.Origins = new HashSet { globalSettings.BaseServiceUri.Vault, }; options.TimestampDriftTolerance = 300000; + + if (globalSettings.Fido2?.Origins?.Any() == true) + { + options.Origins = new HashSet(globalSettings.Fido2.Origins); + } + else + { + // Default to allowing the vault domain and chromium browser extension IDs + options.Origins = new HashSet { + globalSettings.BaseServiceUri.Vault, + Constants.BrowserExtensions.ChromeId, + Constants.BrowserExtensions.EdgeId, + Constants.BrowserExtensions.OperaId + }; + } + }); } @@ -1014,4 +849,181 @@ public static class ServiceCollectionExtensions return (provider, connectionString); } + + private static IServiceCollection AddAzureServiceBusIntegration(this IServiceCollection services, + TListenerConfig listenerConfiguration) + where TConfig : class + where TListenerConfig : IIntegrationListenerConfiguration + { + services.TryAddKeyedSingleton(serviceKey: listenerConfiguration.RoutingKey, implementationFactory: (provider, _) => + new EventIntegrationHandler( + integrationType: listenerConfiguration.IntegrationType, + eventIntegrationPublisher: provider.GetRequiredService(), + integrationFilterService: provider.GetRequiredService(), + configurationCache: provider.GetRequiredService(), + userRepository: provider.GetRequiredService(), + organizationRepository: provider.GetRequiredService(), + logger: provider.GetRequiredService>>() + ) + ); + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new AzureServiceBusEventListenerService( + configuration: listenerConfiguration, + handler: provider.GetRequiredKeyedService(serviceKey: listenerConfiguration.RoutingKey), + serviceBusService: provider.GetRequiredService(), + serviceBusOptions: new ServiceBusProcessorOptions() + { + PrefetchCount = listenerConfiguration.EventPrefetchCount, + MaxConcurrentCalls = listenerConfiguration.EventMaxConcurrentCalls + }, + loggerFactory: provider.GetRequiredService() + ) + ) + ); + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new AzureServiceBusIntegrationListenerService( + configuration: listenerConfiguration, + handler: provider.GetRequiredService>(), + serviceBusService: provider.GetRequiredService(), + serviceBusOptions: new ServiceBusProcessorOptions() + { + PrefetchCount = listenerConfiguration.IntegrationPrefetchCount, + MaxConcurrentCalls = listenerConfiguration.IntegrationMaxConcurrentCalls + }, + loggerFactory: provider.GetRequiredService() + ) + ) + ); + + return services; + } + + private static IServiceCollection AddEventIntegrationServices(this IServiceCollection services, + GlobalSettings globalSettings) + { + // Add common services + services.TryAddSingleton(); + services.TryAddSingleton(provider => + provider.GetRequiredService()); + services.AddHostedService(provider => provider.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddKeyedSingleton("persistent"); + + // Add services in support of handlers + services.AddSlackService(globalSettings); + services.TryAddSingleton(TimeProvider.System); + services.AddHttpClient(WebhookIntegrationHandler.HttpClientName); + services.AddHttpClient(DatadogIntegrationHandler.HttpClientName); + + // Add integration handlers + services.TryAddSingleton, SlackIntegrationHandler>(); + services.TryAddSingleton, WebhookIntegrationHandler>(); + services.TryAddSingleton, DatadogIntegrationHandler>(); + + var repositoryConfiguration = new RepositoryListenerConfiguration(globalSettings); + var slackConfiguration = new SlackListenerConfiguration(globalSettings); + var webhookConfiguration = new WebhookListenerConfiguration(globalSettings); + var hecConfiguration = new HecListenerConfiguration(globalSettings); + var datadogConfiguration = new DatadogListenerConfiguration(globalSettings); + + if (IsRabbitMqEnabled(globalSettings)) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new RabbitMqEventListenerService( + handler: provider.GetRequiredService(), + configuration: repositoryConfiguration, + rabbitMqService: provider.GetRequiredService(), + loggerFactory: provider.GetRequiredService() + ) + ) + ); + services.AddRabbitMqIntegration(slackConfiguration); + services.AddRabbitMqIntegration(webhookConfiguration); + services.AddRabbitMqIntegration(hecConfiguration); + services.AddRabbitMqIntegration(datadogConfiguration); + } + + if (IsAzureServiceBusEnabled(globalSettings)) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new AzureServiceBusEventListenerService( + configuration: repositoryConfiguration, + handler: provider.GetRequiredService(), + serviceBusService: provider.GetRequiredService(), + serviceBusOptions: new ServiceBusProcessorOptions() + { + PrefetchCount = repositoryConfiguration.EventPrefetchCount, + MaxConcurrentCalls = repositoryConfiguration.EventMaxConcurrentCalls + }, + loggerFactory: provider.GetRequiredService() + ) + ) + ); + services.AddAzureServiceBusIntegration(slackConfiguration); + services.AddAzureServiceBusIntegration(webhookConfiguration); + services.AddAzureServiceBusIntegration(hecConfiguration); + services.AddAzureServiceBusIntegration(datadogConfiguration); + } + + return services; + } + + private static IServiceCollection AddRabbitMqIntegration(this IServiceCollection services, + TListenerConfig listenerConfiguration) + where TConfig : class + where TListenerConfig : IIntegrationListenerConfiguration + { + services.TryAddKeyedSingleton(serviceKey: listenerConfiguration.RoutingKey, implementationFactory: (provider, _) => + new EventIntegrationHandler( + integrationType: listenerConfiguration.IntegrationType, + eventIntegrationPublisher: provider.GetRequiredService(), + integrationFilterService: provider.GetRequiredService(), + configurationCache: provider.GetRequiredService(), + userRepository: provider.GetRequiredService(), + organizationRepository: provider.GetRequiredService(), + logger: provider.GetRequiredService>>() + ) + ); + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new RabbitMqEventListenerService( + handler: provider.GetRequiredKeyedService(serviceKey: listenerConfiguration.RoutingKey), + configuration: listenerConfiguration, + rabbitMqService: provider.GetRequiredService(), + loggerFactory: provider.GetRequiredService() + ) + ) + ); + services.TryAddEnumerable(ServiceDescriptor.Singleton>(provider => + new RabbitMqIntegrationListenerService( + handler: provider.GetRequiredService>(), + configuration: listenerConfiguration, + rabbitMqService: provider.GetRequiredService(), + loggerFactory: provider.GetRequiredService(), + timeProvider: provider.GetRequiredService() + ) + ) + ); + + return services; + } + + private static bool IsAzureServiceBusEnabled(GlobalSettings settings) + { + return CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.ConnectionString) && + CoreHelpers.SettingHasValue(settings.EventLogging.AzureServiceBus.EventTopicName); + } + + private static bool IsRabbitMqEnabled(GlobalSettings settings) + { + return CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.HostName) && + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Username) && + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.Password) && + CoreHelpers.SettingHasValue(settings.EventLogging.RabbitMq.EventExchangeName); + } } diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 849fd3bdfd..1a7530321e 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -11,8 +11,6 @@ - - diff --git a/src/Sql/dbo/Auth/Stored Procedures/AuthRequest_ReadPendingByUserId.sql b/src/Sql/dbo/Auth/Stored Procedures/AuthRequest_ReadPendingByUserId.sql new file mode 100644 index 0000000000..4c3217812a --- /dev/null +++ b/src/Sql/dbo/Auth/Stored Procedures/AuthRequest_ReadPendingByUserId.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[AuthRequest_ReadPendingByUserId] + @UserId UNIQUEIDENTIFIER, + @ExpirationMinutes INT +AS +BEGIN + SET NOCOUNT ON + + SELECT * + FROM [dbo].[AuthRequestPendingDetailsView] + WHERE [UserId] = @UserId + AND [CreationDate] >= DATEADD(MINUTE, -@ExpirationMinutes, GETUTCDATE()) +END diff --git a/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql b/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql new file mode 100644 index 0000000000..16f8a51195 --- /dev/null +++ b/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql @@ -0,0 +1,38 @@ +CREATE VIEW [dbo].[AuthRequestPendingDetailsView] +AS + WITH + PendingRequests + AS + ( + SELECT + [AR].*, + [D].[Id] AS [DeviceId], + ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier], [AR].[UserId] ORDER BY [AR].[CreationDate] DESC) AS [rn] + FROM [dbo].[AuthRequest] [AR] + LEFT JOIN [dbo].[Device] [D] + ON [AR].[RequestDeviceIdentifier] = [D].[Identifier] + AND [D].[UserId] = [AR].[UserId] + WHERE [AR].[Type] IN (0, 1) -- 0 = AuthenticateAndUnlock, 1 = Unlock + ) + SELECT + [PR].[Id], + [PR].[UserId], + [PR].[OrganizationId], + [PR].[Type], + [PR].[RequestDeviceIdentifier], + [PR].[RequestDeviceType], + [PR].[RequestIpAddress], + [PR].[RequestCountryName], + [PR].[ResponseDeviceId], + [PR].[AccessCode], + [PR].[PublicKey], + [PR].[Key], + [PR].[MasterPasswordHash], + [PR].[Approved], + [PR].[CreationDate], + [PR].[ResponseDate], + [PR].[AuthenticationDate], + [PR].[DeviceId] + FROM [PendingRequests] [PR] + WHERE [PR].[rn] = 1 + AND [PR].[Approved] IS NULL -- since we only want pending requests we only want the most recent that is also approved = null diff --git a/src/Sql/Dirt/Stored Procedure/MemberAccessDetail_GetMemberAccessDetailByOrganizationId.sql b/src/Sql/dbo/Dirt/Stored Procedures/MemberAccessDetail_GetMemberAccessDetailByOrganizationId.sql similarity index 100% rename from src/Sql/Dirt/Stored Procedure/MemberAccessDetail_GetMemberAccessDetailByOrganizationId.sql rename to src/Sql/dbo/Dirt/Stored Procedures/MemberAccessDetail_GetMemberAccessDetailByOrganizationId.sql diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationApplication_Create.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationApplication_Create.sql index b2bb8593ef..eff67e696b 100644 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationApplication_Create.sql +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationApplication_Create.sql @@ -3,7 +3,8 @@ CREATE PROCEDURE [dbo].[OrganizationApplication_Create] @OrganizationId UNIQUEIDENTIFIER, @Applications NVARCHAR(MAX), @CreationDate DATETIME2(7), - @RevisionDate DATETIME2(7) + @RevisionDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX) AS SET NOCOUNT ON; @@ -13,13 +14,15 @@ AS [OrganizationId], [Applications], [CreationDate], - [RevisionDate] + [RevisionDate], + [ContentEncryptionKey] ) VALUES - ( + ( @Id, @OrganizationId, @Applications, @CreationDate, - @RevisionDate - ); + @RevisionDate, + @ContentEncryptionKey + ); diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql index d0cea4d73b..d6cd206558 100644 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql @@ -1,23 +1,35 @@ CREATE PROCEDURE [dbo].[OrganizationReport_Create] - @Id UNIQUEIDENTIFIER OUTPUT, - @OrganizationId UNIQUEIDENTIFIER, - @Date DATETIME2(7), - @ReportData NVARCHAR(MAX), - @CreationDate DATETIME2(7) + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX), + @SummaryData NVARCHAR(MAX), + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) AS - SET NOCOUNT ON; +BEGIN + SET NOCOUNT ON; - INSERT INTO [dbo].[OrganizationReport]( - [Id], - [OrganizationId], - [Date], - [ReportData], - [CreationDate] - ) - VALUES ( - @Id, - @OrganizationId, - @Date, - @ReportData, - @CreationDate + +INSERT INTO [dbo].[OrganizationReport]( + [Id], + [OrganizationId], + [ReportData], + [CreationDate], + [ContentEncryptionKey], + [SummaryData], + [ApplicationData], + [RevisionDate] +) +VALUES ( + @Id, + @OrganizationId, + @ReportData, + @CreationDate, + @ContentEncryptionKey, + @SummaryData, + @ApplicationData, + @RevisionDate ); +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetApplicationDataById.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetApplicationDataById.sql new file mode 100644 index 0000000000..83c97b76ee --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetApplicationDataById.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetApplicationDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [ApplicationData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id +END + diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql new file mode 100644 index 0000000000..1312369fa8 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql @@ -0,0 +1,19 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetLatestByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP 1 + [Id], + [OrganizationId], + [ReportData], + [CreationDate], + [ContentEncryptionKey], + [SummaryData], + [ApplicationData], + [RevisionDate] + FROM [dbo].[OrganizationReportView] + WHERE [OrganizationId] = @OrganizationId + ORDER BY [RevisionDate] DESC +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetReportDataById.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetReportDataById.sql new file mode 100644 index 0000000000..9905d5aad2 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetReportDataById.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetReportDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [ReportData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id +END + diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql new file mode 100644 index 0000000000..2ab78a2a1e --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetSummariesByDateRange] + @OrganizationId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [SummaryData] + FROM [dbo].[OrganizationReportView] + WHERE [OrganizationId] = @OrganizationId + AND [RevisionDate] >= @StartDate + AND [RevisionDate] <= @EndDate + ORDER BY [RevisionDate] DESC +END + diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummaryDataById.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummaryDataById.sql new file mode 100644 index 0000000000..ff0023c95b --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummaryDataById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetSummaryDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [SummaryData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id +END + + diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationId.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationId.sql deleted file mode 100644 index 6bdcf51f70..0000000000 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationId.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE PROCEDURE [dbo].[OrganizationReport_ReadByOrganizationId] - @OrganizationId UNIQUEIDENTIFIER -AS - SET NOCOUNT ON; - - SELECT - * - FROM [dbo].[OrganizationReportView] - WHERE [OrganizationId] = @OrganizationId; diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql new file mode 100644 index 0000000000..4732fb8ef4 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql @@ -0,0 +1,23 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX), + @SummaryData NVARCHAR(MAX), + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + UPDATE [dbo].[OrganizationReport] + SET + [OrganizationId] = @OrganizationId, + [ReportData] = @ReportData, + [CreationDate] = @CreationDate, + [ContentEncryptionKey] = @ContentEncryptionKey, + [SummaryData] = @SummaryData, + [ApplicationData] = @ApplicationData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id; +END; diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateApplicationData.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateApplicationData.sql new file mode 100644 index 0000000000..573622a5e0 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateApplicationData.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_UpdateApplicationData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE [dbo].[OrganizationReport] + SET + [ApplicationData] = @ApplicationData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateReportData.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateReportData.sql new file mode 100644 index 0000000000..d7172e100e --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateReportData.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_UpdateReportData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE [dbo].[OrganizationReport] + SET + [ReportData] = @ReportData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateSummaryData.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateSummaryData.sql new file mode 100644 index 0000000000..f33f5980e8 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateSummaryData.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_UpdateSummaryData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @SummaryData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE [dbo].[OrganizationReport] + SET + [SummaryData] = @SummaryData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END diff --git a/src/Sql/dbo/Dirt/Tables/OrganizationApplication.sql b/src/Sql/dbo/Dirt/Tables/OrganizationApplication.sql index 58c8080e23..76cb8356a0 100644 --- a/src/Sql/dbo/Dirt/Tables/OrganizationApplication.sql +++ b/src/Sql/dbo/Dirt/Tables/OrganizationApplication.sql @@ -4,9 +4,10 @@ CREATE TABLE [dbo].[OrganizationApplication] ( [Applications] NVARCHAR(MAX) NOT NULL, [CreationDate] DATETIME2 (7) NOT NULL, [RevisionDate] DATETIME2 (7) NOT NULL, + [ContentEncryptionKey] VARCHAR(MAX) NOT NULL, CONSTRAINT [PK_OrganizationApplication] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [FK_OrganizationApplication_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) - ); +); GO CREATE NONCLUSTERED INDEX [IX_OrganizationApplication_OrganizationId] diff --git a/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql b/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql index 563877a340..4c47eafad8 100644 --- a/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql +++ b/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql @@ -1,18 +1,24 @@ CREATE TABLE [dbo].[OrganizationReport] ( [Id] UNIQUEIDENTIFIER NOT NULL, [OrganizationId] UNIQUEIDENTIFIER NOT NULL, - [Date] DATETIME2 (7) NOT NULL, [ReportData] NVARCHAR(MAX) NOT NULL, [CreationDate] DATETIME2 (7) NOT NULL, + [ContentEncryptionKey] VARCHAR(MAX) NOT NULL, + [SummaryData] NVARCHAR(MAX) NULL, + [ApplicationData] NVARCHAR(MAX) NULL, + [RevisionDate] DATETIME2 (7) NULL, CONSTRAINT [PK_OrganizationReport] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [FK_OrganizationReport_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ); GO + CREATE NONCLUSTERED INDEX [IX_OrganizationReport_OrganizationId] - ON [dbo].[OrganizationReport]([OrganizationId] ASC); + ON [dbo].[OrganizationReport] ([OrganizationId] ASC); GO -CREATE NONCLUSTERED INDEX [IX_OrganizationReport_OrganizationId_Date] - ON [dbo].[OrganizationReport]([OrganizationId] ASC, [Date] DESC); + +CREATE NONCLUSTERED INDEX [IX_OrganizationReport_OrganizationId_RevisionDate] + ON [dbo].[OrganizationReport]([OrganizationId] ASC, [RevisionDate] DESC); GO + diff --git a/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql index 5dc950ffff..831c9f70ee 100644 --- a/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql +++ b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql @@ -18,7 +18,7 @@ BEGIN AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) AND [OrganizationId] = @OrganizationId - AND [ServiceAccountId] = @ServiceAccountId + AND ([ServiceAccountId] = @ServiceAccountId OR [GrantedServiceAccountId] = @ServiceAccountId) ORDER BY [Date] DESC OFFSET 0 ROWS FETCH NEXT @PageSize ROWS ONLY diff --git a/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByProjectId.sql b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByProjectId.sql new file mode 100644 index 0000000000..61a4e55b69 --- /dev/null +++ b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByProjectId.sql @@ -0,0 +1,44 @@ +CREATE PROCEDURE [dbo].[Event_ReadPageByProjectId] + @ProjectId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7), + @BeforeDate DATETIME2(7), + @PageSize INT +AS +BEGIN + SET NOCOUNT ON + + SELECT + e.Id, + e.Date, + e.Type, + e.UserId, + e.OrganizationId, + e.InstallationId, + e.ProviderId, + e.CipherId, + e.CollectionId, + e.PolicyId, + e.GroupId, + e.OrganizationUserId, + e.ProviderUserId, + e.ProviderOrganizationId, + e.DeviceType, + e.IpAddress, + e.ActingUserId, + e.SystemUser, + e.DomainName, + e.SecretId, + e.ServiceAccountId, + e.ProjectId + FROM + [dbo].[EventView] e + WHERE + [Date] >= @StartDate + AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) + AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) + AND [ProjectId] = @ProjectId + ORDER BY [Date] DESC + OFFSET 0 ROWS + FETCH NEXT @PageSize ROWS ONLY +END diff --git a/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageBySecretId.sql b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageBySecretId.sql new file mode 100644 index 0000000000..d72d275e64 --- /dev/null +++ b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageBySecretId.sql @@ -0,0 +1,44 @@ +CREATE PROCEDURE [dbo].[Event_ReadPageBySecretId] + @SecretId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7), + @BeforeDate DATETIME2(7), + @PageSize INT +AS +BEGIN + SET NOCOUNT ON + + SELECT + e.Id, + e.Date, + e.Type, + e.UserId, + e.OrganizationId, + e.InstallationId, + e.ProviderId, + e.CipherId, + e.CollectionId, + e.PolicyId, + e.GroupId, + e.OrganizationUserId, + e.ProviderUserId, + e.ProviderOrganizationId, + e.DeviceType, + e.IpAddress, + e.ActingUserId, + e.SystemUser, + e.DomainName, + e.SecretId, + e.ServiceAccountId, + e.ProjectId + FROM + [dbo].[EventView] e + WHERE + [Date] >= @StartDate + AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) + AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) + AND [SecretId] = @SecretId + ORDER BY [Date] DESC + OFFSET 0 ROWS + FETCH NEXT @PageSize ROWS ONLY +END diff --git a/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByServiceAccountId.sql b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByServiceAccountId.sql new file mode 100644 index 0000000000..c429a4a064 --- /dev/null +++ b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByServiceAccountId.sql @@ -0,0 +1,45 @@ +CREATE PROCEDURE [dbo].[Event_ReadPageByServiceAccountId] + @GrantedServiceAccountId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7), + @BeforeDate DATETIME2(7), + @PageSize INT +AS +BEGIN + SET NOCOUNT ON + + SELECT + e.Id, + e.Date, + e.Type, + e.UserId, + e.OrganizationId, + e.InstallationId, + e.ProviderId, + e.CipherId, + e.CollectionId, + e.PolicyId, + e.GroupId, + e.OrganizationUserId, + e.ProviderUserId, + e.ProviderOrganizationId, + e.DeviceType, + e.IpAddress, + e.ActingUserId, + e.SystemUser, + e.DomainName, + e.SecretId, + e.ServiceAccountId, + e.ProjectId, + e.GrantedServiceAccountId + FROM + [dbo].[EventView] e + WHERE + [Date] >= @StartDate + AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) + AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) + AND [GrantedServiceAccountId] = @GrantedServiceAccountId + ORDER BY [Date] DESC + OFFSET 0 ROWS + FETCH NEXT @PageSize ROWS ONLY +END diff --git a/src/Sql/dbo/Stored Procedures/CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections.sql b/src/Sql/dbo/Stored Procedures/CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections.sql new file mode 100644 index 0000000000..c678386f8a --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections.sql @@ -0,0 +1,39 @@ + -- Stored procedure that filters out ciphers that ONLY belong to default collections +CREATE PROCEDURE + [dbo].[CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections] + @OrganizationId UNIQUEIDENTIFIER + AS + BEGIN + SET NOCOUNT ON; + + WITH [NonDefaultCiphers] AS ( + SELECT DISTINCT [Id] + FROM [dbo].[OrganizationCipherDetailsCollectionsView] + WHERE [OrganizationId] = @OrganizationId + AND ([CollectionId] IS NULL + OR [CollectionType] <> 1) + ) + + SELECT + V.[Id], + V.[UserId], + V.[OrganizationId], + V.[Type], + V.[Data], + V.[Favorites], + V.[Folders], + V.[Attachments], + V.[CreationDate], + V.[RevisionDate], + V.[DeletedDate], + V.[Reprompt], + V.[Key], + V.[OrganizationUseTotp], + V.[CollectionId] -- For Dapper splitOn parameter + FROM [dbo].[OrganizationCipherDetailsCollectionsView] V + INNER JOIN [NonDefaultCiphers] NDC ON V.[Id] = NDC.[Id] + WHERE V.[OrganizationId] = @OrganizationId + AND (V.[CollectionId] IS NULL OR V.[CollectionType] <> 1) + ORDER BY V.[RevisionDate] DESC; + END; + GO \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql new file mode 100644 index 0000000000..d35dabb0e4 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[CollectionCipher_ReadSharedByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + CC.[CollectionId], + CC.[CipherId] + FROM + [dbo].[CollectionCipher] CC + INNER JOIN + [dbo].[Collection] C ON C.[Id] = CC.[CollectionId] + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Type] = 0 -- SharedCollections only +END diff --git a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollections.sql b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollections.sql index f3a1d964b5..2282524228 100644 --- a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollections.sql +++ b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollections.sql @@ -44,13 +44,13 @@ BEGIN [CollectionId], [CipherId] ) - SELECT + SELECT [Id], @CipherId FROM @CollectionIds WHERE [Id] IN (SELECT [Id] FROM [#TempAvailableCollections]) AND NOT EXISTS ( - SELECT 1 + SELECT 1 FROM [dbo].[CollectionCipher] WHERE [CollectionId] = [@CollectionIds].[Id] AND [CipherId] = @CipherId diff --git a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsAdmin.sql b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsAdmin.sql index 5f7b0215d9..1486709f09 100644 --- a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsAdmin.sql +++ b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsAdmin.sql @@ -4,46 +4,52 @@ @CollectionIds AS [dbo].[GuidIdArray] READONLY AS BEGIN - SET NOCOUNT ON + SET NOCOUNT ON; - ;WITH [AvailableCollectionsCTE] AS( - SELECT - Id - FROM - [dbo].[Collection] - WHERE - OrganizationId = @OrganizationId - ), - [CollectionCiphersCTE] AS( - SELECT - [CollectionId], - [CipherId] - FROM - [dbo].[CollectionCipher] - WHERE - [CipherId] = @CipherId + -- Available collections for this org, excluding default collections + SELECT + C.[Id] + INTO #TempAvailableCollections + FROM [dbo].[Collection] AS C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Type] <> 1; -- exclude DefaultUserCollection + + -- Insert new collection assignments + INSERT INTO [dbo].[CollectionCipher] ( + [CollectionId], + [CipherId] ) - MERGE - [CollectionCiphersCTE] AS [Target] - USING - @CollectionIds AS [Source] - ON - [Target].[CollectionId] = [Source].[Id] - AND [Target].[CipherId] = @CipherId - WHEN NOT MATCHED BY TARGET - AND [Source].[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) THEN - INSERT VALUES - ( - [Source].[Id], - @CipherId - ) - WHEN NOT MATCHED BY SOURCE - AND [Target].[CipherId] = @CipherId THEN - DELETE - ; + SELECT + S.[Id], + @CipherId + FROM @CollectionIds AS S + INNER JOIN #TempAvailableCollections AS A + ON A.[Id] = S.[Id] + WHERE NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionCipher] AS CC + WHERE CC.[CollectionId] = S.[Id] + AND CC.[CipherId] = @CipherId + ); + + -- Delete removed collection assignments + DELETE CC + FROM [dbo].[CollectionCipher] AS CC + INNER JOIN #TempAvailableCollections AS A + ON A.[Id] = CC.[CollectionId] + WHERE CC.[CipherId] = @CipherId + AND NOT EXISTS ( + SELECT 1 + FROM @CollectionIds AS S + WHERE S.[Id] = CC.[CollectionId] + ); IF @OrganizationId IS NOT NULL BEGIN - EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId; END -END \ No newline at end of file + + DROP TABLE #TempAvailableCollections; +END +GO diff --git a/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationUserIds.sql b/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationUserIds.sql index c78d7390a7..b3cf499f77 100644 --- a/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationUserIds.sql +++ b/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationUserIds.sql @@ -10,6 +10,10 @@ BEGIN [dbo].[OrganizationUser] OU INNER JOIN [dbo].[CollectionUser] CU ON CU.[OrganizationUserId] = OU.[Id] + INNER JOIN + [dbo].[Collection] C ON CU.[CollectionId] = C.[Id] INNER JOIN @OrganizationUserIds OUI ON OUI.[Id] = OU.[Id] + WHERE + C.[Type] != 1 -- Exclude DefaultUserCollection END diff --git a/src/Sql/dbo/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationId.sql new file mode 100644 index 0000000000..9f54a7e10e --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Collection_ReadSharedCollectionsByOrganizationId.sql @@ -0,0 +1,14 @@ +CREATE PROCEDURE [dbo].[Collection_ReadSharedCollectionsByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[CollectionView] + WHERE + [OrganizationId] = @OrganizationId AND + [Type] = 0 -- SharedCollections only +END \ No newline at end of file diff --git a/src/Sql/dbo/Stored Procedures/Event_Create.sql b/src/Sql/dbo/Stored Procedures/Event_Create.sql index cd3dd6b6e9..0466bc1a69 100644 --- a/src/Sql/dbo/Stored Procedures/Event_Create.sql +++ b/src/Sql/dbo/Stored Procedures/Event_Create.sql @@ -19,7 +19,9 @@ @SystemUser TINYINT = null, @DomainName VARCHAR(256), @SecretId UNIQUEIDENTIFIER = null, - @ServiceAccountId UNIQUEIDENTIFIER = null + @ServiceAccountId UNIQUEIDENTIFIER = null, + @ProjectId UNIQUEIDENTIFIER = null, + @GrantedServiceAccountId UNIQUEIDENTIFIER = null AS BEGIN SET NOCOUNT ON @@ -46,7 +48,9 @@ BEGIN [SystemUser], [DomainName], [SecretId], - [ServiceAccountId] + [ServiceAccountId], + [ProjectId], + [GrantedServiceAccountId] ) VALUES ( @@ -70,6 +74,8 @@ BEGIN @SystemUser, @DomainName, @SecretId, - @ServiceAccountId + @ServiceAccountId, + @ProjectId, + @GrantedServiceAccountId ) END diff --git a/src/Sql/dbo/Stored Procedures/Notification_MarkAsDeletedByTask.sql b/src/Sql/dbo/Stored Procedures/Notification_MarkAsDeletedByTask.sql new file mode 100644 index 0000000000..a2c16079f7 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Notification_MarkAsDeletedByTask.sql @@ -0,0 +1,35 @@ +CREATE PROCEDURE [dbo].[Notification_MarkAsDeletedByTask] + @TaskId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON; + + -- Collect UserIds as they are altered + DECLARE @UserIdsForAlteredNotifications TABLE ( + UserId UNIQUEIDENTIFIER + ); + + -- Update existing NotificationStatus as deleted + UPDATE ns + SET ns.DeletedDate = GETUTCDATE() + OUTPUT inserted.UserId INTO @UserIdsForAlteredNotifications + FROM NotificationStatus ns + INNER JOIN Notification n ON ns.NotificationId = n.Id + WHERE n.TaskId = @TaskId + AND ns.DeletedDate IS NULL; + + -- Insert NotificationStatus records for notifications that don't have one yet + INSERT INTO NotificationStatus (NotificationId, UserId, DeletedDate) + OUTPUT inserted.UserId INTO @UserIdsForAlteredNotifications + SELECT n.Id, n.UserId, GETUTCDATE() + FROM Notification n + LEFT JOIN NotificationStatus ns + ON n.Id = ns.NotificationId + WHERE n.TaskId = @TaskId + AND ns.NotificationId IS NULL; + + -- Return the UserIds associated with the altered notifications + SELECT u.UserId + FROM @UserIdsForAlteredNotifications u; +END +GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadMany.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadMany.sql new file mode 100644 index 0000000000..9c65ef58af --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfigurationDetails_ReadMany.sql @@ -0,0 +1,11 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegrationConfigurationDetails_ReadMany] +AS +BEGIN + SET NOCOUNT ON + + SELECT + oic.* + FROM + [dbo].[OrganizationIntegrationConfigurationDetailsView] oic +END +GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadManyByOrganizationIntegrationId.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadManyByOrganizationIntegrationId.sql new file mode 100644 index 0000000000..b187ff1d5f --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegrationConfiguration_ReadManyByOrganizationIntegrationId.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegrationConfiguration_ReadManyByOrganizationIntegrationId] + @OrganizationIntegrationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + +SELECT + * +FROM + [dbo].[OrganizationIntegrationConfigurationView] +WHERE + [OrganizationIntegrationId] = @OrganizationIntegrationId +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadManyByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadManyByOrganizationId.sql new file mode 100644 index 0000000000..939cfc0288 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadManyByOrganizationId.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegration_ReadManyByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + +SELECT + * +FROM + [dbo].[OrganizationIntegrationView] +WHERE + [OrganizationId] = @OrganizationId +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadByOrganizationId_V2.sql b/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadByOrganizationId_V2.sql new file mode 100644 index 0000000000..d581b3aa2c --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadByOrganizationId_V2.sql @@ -0,0 +1,33 @@ +CREATE PROCEDURE [dbo].[OrganizationUserUserDetails_ReadByOrganizationId_V2] + @OrganizationId UNIQUEIDENTIFIER, + @IncludeGroups BIT = 0, + @IncludeCollections BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + -- Result Set 1: User Details (always returned) + SELECT * + FROM [dbo].[OrganizationUserUserDetailsView] + WHERE OrganizationId = @OrganizationId + + -- Result Set 2: Group associations (if requested) + IF @IncludeGroups = 1 + BEGIN + SELECT gu.* + FROM [dbo].[GroupUser] gu + INNER JOIN [dbo].[OrganizationUser] ou ON gu.OrganizationUserId = ou.Id + WHERE ou.OrganizationId = @OrganizationId + END + + -- Result Set 3: Collection associations (if requested) + IF @IncludeCollections = 1 + BEGIN + SELECT cu.* + FROM [dbo].[CollectionUser] cu + INNER JOIN [dbo].[OrganizationUser] ou ON cu.OrganizationUserId = ou.Id + INNER JOIN [dbo].[Collection] c ON cu.CollectionId = c.Id + WHERE ou.OrganizationId = @OrganizationId + AND c.Type = 0 -- SharedCollections only + END +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadWithCollectionsById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadWithCollectionsById.sql index b76e4b8775..ed683d8392 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadWithCollectionsById.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadWithCollectionsById.sql @@ -15,6 +15,9 @@ BEGIN [dbo].[OrganizationUser] OU INNER JOIN [dbo].[CollectionUser] CU ON CU.[OrganizationUserId] = [OU].[Id] + INNER JOIN + [dbo].[Collection] C ON CU.[CollectionId] = C.[Id] WHERE [OrganizationUserId] = @Id + AND C.[Type] != 1 -- Exclude default user collections END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteById.sql index d706bd4d75..fc95cb112a 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteById.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[OrganizationUser_DeleteById] +CREATE PROCEDURE [dbo].[OrganizationUser_DeleteById] @Id UNIQUEIDENTIFIER AS BEGIN @@ -17,6 +17,11 @@ BEGIN WHERE [Id] = @Id + -- Migrate DefaultUserCollection to SharedCollection + DECLARE @Ids [dbo].[GuidIdArray] + INSERT INTO @Ids (Id) VALUES (@Id) + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @Ids + IF @OrganizationId IS NOT NULL AND @UserId IS NOT NULL BEGIN EXEC [dbo].[SsoUser_Delete] @UserId, @OrganizationId diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql index ac9e75dd5e..79e060c323 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_DeleteByIds.sql @@ -6,6 +6,9 @@ BEGIN EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @Ids + -- Migrate DefaultCollection to SharedCollection + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @Ids + DECLARE @UserAndOrganizationIds [dbo].[TwoGuidIdArray] INSERT INTO @UserAndOrganizationIds diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_MigrateDefaultCollection.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_MigrateDefaultCollection.sql new file mode 100644 index 0000000000..f65cdc3983 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_MigrateDefaultCollection.sql @@ -0,0 +1,22 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_MigrateDefaultCollection] + @Ids [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + DECLARE @UtcNow DATETIME2(7) = GETUTCDATE(); + + UPDATE c + SET + [DefaultUserCollectionEmail] = CASE WHEN c.[DefaultUserCollectionEmail] IS NULL THEN u.[Email] ELSE c.[DefaultUserCollectionEmail] END, + [RevisionDate] = @UtcNow, + [Type] = 0 + FROM + [dbo].[Collection] c + INNER JOIN [dbo].[CollectionUser] cu ON c.[Id] = cu.[CollectionId] + INNER JOIN [dbo].[OrganizationUser] ou ON cu.[OrganizationUserId] = ou.[Id] + INNER JOIN [dbo].[User] u ON ou.[UserId] = u.[Id] + INNER JOIN @Ids i ON ou.[Id] = i.[Id] + WHERE + c.[Type] = 1 +END +GO diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql new file mode 100644 index 0000000000..64f3d81e08 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql @@ -0,0 +1,27 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON; + + WITH OrgUsers AS ( + SELECT * + FROM [dbo].[OrganizationUserView] + WHERE [OrganizationId] = @OrganizationId + ), + UserDomains AS ( + SELECT U.[Id], U.[EmailDomain] + FROM [dbo].[UserEmailDomainView] U + WHERE EXISTS ( + SELECT 1 + FROM [dbo].[OrganizationDomainView] OD + WHERE OD.[OrganizationId] = @OrganizationId + AND OD.[VerifiedDate] IS NOT NULL + AND OD.[DomainName] = U.[EmailDomain] + ) + ) + SELECT OU.* + FROM OrgUsers OU + JOIN UserDomains UD ON OU.[UserId] = UD.[Id] + OPTION (RECOMPILE); +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersById.sql deleted file mode 100644 index 18b876775e..0000000000 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersById.sql +++ /dev/null @@ -1,29 +0,0 @@ -CREATE PROCEDURE [dbo].[OrganizationUser_SetStatusForUsersById] - @OrganizationUserIds AS NVARCHAR(MAX), - @Status SMALLINT -AS -BEGIN - SET NOCOUNT ON - - -- Declare a table variable to hold the parsed JSON data - DECLARE @ParsedIds TABLE (Id UNIQUEIDENTIFIER); - - -- Parse the JSON input into the table variable - INSERT INTO @ParsedIds (Id) - SELECT value - FROM OPENJSON(@OrganizationUserIds); - - -- Check if the input table is empty - IF (SELECT COUNT(1) FROM @ParsedIds) < 1 - BEGIN - RETURN(-1); - END - - UPDATE - [dbo].[OrganizationUser] - SET [Status] = @Status - WHERE [Id] IN (SELECT Id from @ParsedIds) - - EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIdsJson] @OrganizationUserIds -END - diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql index 6486e002c3..e030958c3e 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql @@ -72,8 +72,11 @@ BEGIN CU FROM [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[Collection] C ON C.[Id] = CU.[CollectionId] WHERE CU.[OrganizationUserId] = @Id + AND C.[Type] != 1 -- Don't delete default collections AND NOT EXISTS ( SELECT 1 diff --git a/src/Sql/dbo/Stored Procedures/Organization_Create.sql b/src/Sql/dbo/Stored Procedures/Organization_Create.sql index dc793351f7..295ebb51a8 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Create.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Create.sql @@ -57,7 +57,8 @@ CREATE PROCEDURE [dbo].[Organization_Create] @UseRiskInsights BIT = 0, @LimitItemDeletion BIT = 0, @UseOrganizationDomains BIT = 0, - @UseAdminSponsoredFamilies BIT = 0 + @UseAdminSponsoredFamilies BIT = 0, + @SyncSeats BIT = 0 AS BEGIN SET NOCOUNT ON @@ -122,7 +123,8 @@ BEGIN [UseRiskInsights], [LimitItemDeletion], [UseOrganizationDomains], - [UseAdminSponsoredFamilies] + [UseAdminSponsoredFamilies], + [SyncSeats] ) VALUES ( @@ -184,6 +186,7 @@ BEGIN @UseRiskInsights, @LimitItemDeletion, @UseOrganizationDomains, - @UseAdminSponsoredFamilies + @UseAdminSponsoredFamilies, + @SyncSeats ) END diff --git a/src/Sql/dbo/Stored Procedures/Organization_GetOrganizationsForSubscriptionSync.sql b/src/Sql/dbo/Stored Procedures/Organization_GetOrganizationsForSubscriptionSync.sql new file mode 100644 index 0000000000..1c0e8c01ef --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Organization_GetOrganizationsForSubscriptionSync.sql @@ -0,0 +1,7 @@ +CREATE PROCEDURE [dbo].[Organization_GetOrganizationsForSubscriptionSync] +AS +BEGIN + SELECT * + FROM [dbo].[OrganizationView] + WHERE [Seats] IS NOT NULL AND [SyncSeats] = 1 +END diff --git a/src/Sql/dbo/Stored Procedures/Organization_IncrementSeatCount.sql b/src/Sql/dbo/Stored Procedures/Organization_IncrementSeatCount.sql new file mode 100644 index 0000000000..7e3eba1e13 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Organization_IncrementSeatCount.sql @@ -0,0 +1,15 @@ +CREATE PROCEDURE [dbo].[Organization_IncrementSeatCount] + @OrganizationId UNIQUEIDENTIFIER, + @SeatsToAdd INT, + @RequestDate DATETIME2 +AS +BEGIN + SET NOCOUNT ON + + UPDATE [dbo].[Organization] + SET + [Seats] = [Seats] + @SeatsToAdd, + [SyncSeats] = 1, + [RevisionDate] = @RequestDate + WHERE [Id] = @OrganizationId +END diff --git a/src/Sql/dbo/Stored Procedures/Organization_Update.sql b/src/Sql/dbo/Stored Procedures/Organization_Update.sql index 0043993686..d60852bab6 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Update.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Update.sql @@ -57,7 +57,8 @@ CREATE PROCEDURE [dbo].[Organization_Update] @UseRiskInsights BIT = 0, @LimitItemDeletion BIT = 0, @UseOrganizationDomains BIT = 0, - @UseAdminSponsoredFamilies BIT = 0 + @UseAdminSponsoredFamilies BIT = 0, + @SyncSeats BIT = 0 AS BEGIN SET NOCOUNT ON @@ -122,7 +123,8 @@ BEGIN [UseRiskInsights] = @UseRiskInsights, [LimitItemDeletion] = @LimitItemDeletion, [UseOrganizationDomains] = @UseOrganizationDomains, - [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies + [UseAdminSponsoredFamilies] = @UseAdminSponsoredFamilies, + [SyncSeats] = @SyncSeats WHERE [Id] = @Id END diff --git a/src/Sql/dbo/Stored Procedures/Organization_UpdateSubscriptionStatus.sql b/src/Sql/dbo/Stored Procedures/Organization_UpdateSubscriptionStatus.sql new file mode 100644 index 0000000000..224e76f5dd --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Organization_UpdateSubscriptionStatus.sql @@ -0,0 +1,14 @@ +CREATE PROCEDURE [dbo].[Organization_UpdateSubscriptionStatus] + @SuccessfulOrganizations AS [dbo].[GuidIdArray] READONLY, + @SyncDate DATETIME2 +AS +BEGIN + SET NOCOUNT ON + + UPDATE o + SET + [SyncSeats] = 0, + [RevisionDate] = @SyncDate + FROM [dbo].[Organization] o + INNER JOIN @SuccessfulOrganizations success on success.Id = o.Id +END diff --git a/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByOrganizationId.sql new file mode 100644 index 0000000000..3a93687d25 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByOrganizationId.sql @@ -0,0 +1,82 @@ +CREATE PROCEDURE [dbo].[PolicyDetails_ReadByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER, + @PolicyType TINYINT +AS +BEGIN + SET NOCOUNT ON; + + -- Get users in the given organization (@OrganizationId) by matching either on UserId or Email. + ;WITH GivenOrgUsers AS ( + SELECT + OU.[UserId], + U.[Email] + FROM [dbo].[OrganizationUserView] OU + INNER JOIN [dbo].[UserView] U ON U.[Id] = OU.[UserId] + WHERE OU.[OrganizationId] = @OrganizationId + + UNION ALL + + SELECT + U.[Id] AS [UserId], + U.[Email] + FROM [dbo].[OrganizationUserView] OU + INNER JOIN [dbo].[UserView] U ON U.[Email] = OU.[Email] + WHERE OU.[OrganizationId] = @OrganizationId + ), + + -- Retrieve all organization users that match on either UserId or Email from GivenOrgUsers. + AllOrgUsers AS ( + SELECT + OU.[Id] AS [OrganizationUserId], + OU.[UserId], + OU.[OrganizationId], + AU.[Email], + OU.[Type] AS [OrganizationUserType], + OU.[Status] AS [OrganizationUserStatus], + OU.[Permissions] AS [OrganizationUserPermissionsData] + FROM [dbo].[OrganizationUserView] OU + INNER JOIN GivenOrgUsers AU ON AU.[UserId] = OU.[UserId] + UNION ALL + SELECT + OU.[Id] AS [OrganizationUserId], + AU.[UserId], + OU.[OrganizationId], + AU.[Email], + OU.[Type] AS [OrganizationUserType], + OU.[Status] AS [OrganizationUserStatus], + OU.[Permissions] AS [OrganizationUserPermissionsData] + FROM [dbo].[OrganizationUserView] OU + INNER JOIN GivenOrgUsers AU ON AU.[Email] = OU.[Email] + ) + + -- Return policy details for each matching organization user. + SELECT + OU.[OrganizationUserId], + OU.[UserId], + P.[OrganizationId], + P.[Type] AS [PolicyType], + P.[Data] AS [PolicyData], + OU.[OrganizationUserType], + OU.[OrganizationUserStatus], + OU.[OrganizationUserPermissionsData], + -- Check if user is a provider for the organization + CASE + WHEN EXISTS ( + SELECT 1 + FROM [dbo].[ProviderUserView] PU + INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId] + WHERE PU.[UserId] = OU.[UserId] + AND PO.[OrganizationId] = P.[OrganizationId] + ) THEN 1 + ELSE 0 + END AS [IsProvider] + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN AllOrgUsers OU ON OU.[OrganizationId] = O.[Id] + WHERE P.[Enabled] = 1 + AND O.[Enabled] = 1 + AND O.[UsePolicies] = 1 + AND P.[Type] = @PolicyType + +END +GO diff --git a/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql b/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql new file mode 100644 index 0000000000..8686802f87 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql @@ -0,0 +1,83 @@ +CREATE PROCEDURE [dbo].[PolicyDetails_ReadByUserIdsPolicyType] + @UserIds AS [dbo].[GuidIdArray] READONLY, + @PolicyType AS TINYINT +AS +BEGIN + SET NOCOUNT ON; + + WITH AcceptedUsers AS ( + -- Branch 1: Accepted users linked by UserId + SELECT + OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + OU.[UserId] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN @UserIds UI ON OU.[UserId] = UI.Id -- Direct join with TVP + WHERE + P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] != 0 -- Accepted users + AND P.[Type] = @PolicyType + ), + InvitedUsers AS ( + -- Branch 2: Invited users matched by email + SELECT + OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + U.[Id] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN [dbo].[UserView] U ON U.[Email] = OU.[Email] -- Join on email + INNER JOIN @UserIds UI ON U.[Id] = UI.Id -- Join with TVP + WHERE + P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] = 0 -- Invited users only + AND P.[Type] = @PolicyType + ), + AllUsers AS ( + -- Combine both user sets + SELECT * FROM AcceptedUsers + UNION + SELECT * FROM InvitedUsers + ), + ProviderLookup AS ( + -- Pre-calculate provider relationships for all relevant user/org combinations + SELECT DISTINCT + PU.[UserId], + PO.[OrganizationId] + FROM [dbo].[ProviderUserView] PU + INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId] + INNER JOIN AllUsers AU ON PU.[UserId] = AU.UserId AND PO.[OrganizationId] = AU.OrganizationId + ) + -- Final result with efficient IsProvider lookup + SELECT + AU.OrganizationUserId, + AU.OrganizationId, + AU.PolicyType, + AU.PolicyData, + AU.OrganizationUserType, + AU.OrganizationUserStatus, + AU.OrganizationUserPermissionsData, + AU.UserId, + IIF(PL.UserId IS NOT NULL, 1, 0) AS IsProvider + FROM AllUsers AU + LEFT JOIN ProviderLookup PL + ON AU.UserId = PL.UserId + AND AU.OrganizationId = PL.OrganizationId +END diff --git a/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationUserIdsJson.sql b/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationUserIdsJson.sql deleted file mode 100644 index 6e4119d864..0000000000 --- a/src/Sql/dbo/Stored Procedures/User_BumpAccountRevisionDateByOrganizationUserIdsJson.sql +++ /dev/null @@ -1,33 +0,0 @@ -CREATE PROCEDURE [dbo].[User_BumpAccountRevisionDateByOrganizationUserIdsJson] - @OrganizationUserIds NVARCHAR(MAX) -AS -BEGIN - SET NOCOUNT ON - - CREATE TABLE #UserIds - ( - UserId UNIQUEIDENTIFIER NOT NULL - ); - - INSERT INTO #UserIds (UserId) - SELECT - OU.UserId - FROM - [dbo].[OrganizationUser] OU - INNER JOIN - (SELECT [value] as Id FROM OPENJSON(@OrganizationUserIds)) AS OUIds - ON OUIds.Id = OU.Id - WHERE - OU.[Status] = 2 -- Confirmed - - UPDATE - U - SET - U.[AccountRevisionDate] = GETUTCDATE() - FROM - [dbo].[User] U - INNER JOIN - #UserIds ON U.[Id] = #UserIds.[UserId] - - DROP TABLE #UserIds -END diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql index 0608982e37..6377166e17 100644 --- a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql @@ -52,6 +52,16 @@ BEGIN WHERE [UserId] = @Id + -- Migrate DefaultUserCollection to SharedCollection before deleting CollectionUser records + DECLARE @OrgUserIds [dbo].[GuidIdArray] + INSERT INTO @OrgUserIds (Id) + SELECT [Id] FROM [dbo].[OrganizationUser] WHERE [UserId] = @Id + + IF EXISTS (SELECT 1 FROM @OrgUserIds) + BEGIN + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @OrgUserIds + END + -- Delete collection users DELETE CU diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql b/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql index 97ab955f83..cdf3dd7d3a 100644 --- a/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql +++ b/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql @@ -66,6 +66,16 @@ BEGIN WHERE [UserId] IN (SELECT * FROM @ParsedIds) + -- Migrate DefaultUserCollection to SharedCollection before deleting CollectionUser records + DECLARE @OrgUserIds [dbo].[GuidIdArray] + INSERT INTO @OrgUserIds (Id) + SELECT [Id] FROM [dbo].[OrganizationUser] WHERE [UserId] IN (SELECT * FROM @ParsedIds) + + IF EXISTS (SELECT 1 FROM @OrgUserIds) + BEGIN + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @OrgUserIds + END + -- Delete collection users DELETE CU diff --git a/src/Sql/dbo/Tables/Collection.sql b/src/Sql/dbo/Tables/Collection.sql index 03064fd978..2f0d3b943b 100644 --- a/src/Sql/dbo/Tables/Collection.sql +++ b/src/Sql/dbo/Tables/Collection.sql @@ -14,6 +14,6 @@ GO CREATE NONCLUSTERED INDEX [IX_Collection_OrganizationId_IncludeAll] ON [dbo].[Collection]([OrganizationId] ASC) - INCLUDE([CreationDate], [Name], [RevisionDate]); + INCLUDE([CreationDate], [Name], [RevisionDate], [Type]); GO diff --git a/src/Sql/dbo/Tables/CollectionCipher.sql b/src/Sql/dbo/Tables/CollectionCipher.sql index f661cb6fbd..0891b7bc42 100644 --- a/src/Sql/dbo/Tables/CollectionCipher.sql +++ b/src/Sql/dbo/Tables/CollectionCipher.sql @@ -11,3 +11,4 @@ GO CREATE NONCLUSTERED INDEX [IX_CollectionCipher_CipherId] ON [dbo].[CollectionCipher]([CipherId] ASC); +GO diff --git a/src/Sql/dbo/Tables/Event.sql b/src/Sql/dbo/Tables/Event.sql index 1932f103f5..ea0dda5661 100644 --- a/src/Sql/dbo/Tables/Event.sql +++ b/src/Sql/dbo/Tables/Event.sql @@ -20,11 +20,13 @@ [DomainName] VARCHAR(256) NULL, [SecretId] UNIQUEIDENTIFIER NULL, [ServiceAccountId] UNIQUEIDENTIFIER NULL, + [ProjectId] UNIQUEIDENTIFIER NULL, + [GrantedServiceAccountId] UNIQUEIDENTIFIER NULL, CONSTRAINT [PK_Event] PRIMARY KEY CLUSTERED ([Id] ASC) ); GO CREATE NONCLUSTERED INDEX [IX_Event_DateOrganizationIdUserId] - ON [dbo].[Event]([Date] DESC, [OrganizationId] ASC, [ActingUserId] ASC, [CipherId] ASC); + ON [dbo].[Event]([Date] DESC, [OrganizationId] ASC, [ActingUserId] ASC, [CipherId] ASC) INCLUDE ([ServiceAccountId], [GrantedServiceAccountId]); diff --git a/src/Sql/dbo/Tables/NotificationStatus.sql b/src/Sql/dbo/Tables/NotificationStatus.sql index 2f68e2b2f7..2ccb0e0a8a 100644 --- a/src/Sql/dbo/Tables/NotificationStatus.sql +++ b/src/Sql/dbo/Tables/NotificationStatus.sql @@ -5,11 +5,10 @@ CREATE TABLE [dbo].[NotificationStatus] [ReadDate] DATETIME2 (7) NULL, [DeletedDate] DATETIME2 (7) NULL, CONSTRAINT [PK_NotificationStatus] PRIMARY KEY CLUSTERED ([NotificationId] ASC, [UserId] ASC), - CONSTRAINT [FK_NotificationStatus_Notification] FOREIGN KEY ([NotificationId]) REFERENCES [dbo].[Notification] ([Id]), + CONSTRAINT [FK_NotificationStatus_Notification] FOREIGN KEY ([NotificationId]) REFERENCES [dbo].[Notification] ([Id]) ON DELETE CASCADE, CONSTRAINT [FK_NotificationStatus_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ); GO CREATE NONCLUSTERED INDEX [IX_NotificationStatus_UserId] ON [dbo].[NotificationStatus]([UserId] ASC); - diff --git a/src/Sql/dbo/Tables/Organization.sql b/src/Sql/dbo/Tables/Organization.sql index 2accd2134b..897abef1cf 100644 --- a/src/Sql/dbo/Tables/Organization.sql +++ b/src/Sql/dbo/Tables/Organization.sql @@ -58,6 +58,7 @@ CREATE TABLE [dbo].[Organization] ( [UseRiskInsights] BIT NOT NULL CONSTRAINT [DF_Organization_UseRiskInsights] DEFAULT (0), [UseOrganizationDomains] BIT NOT NULL CONSTRAINT [DF_Organization_UseOrganizationDomains] DEFAULT (0), [UseAdminSponsoredFamilies] BIT NOT NULL CONSTRAINT [DF_Organization_UseAdminSponsoredFamilies] DEFAULT (0), + [SyncSeats] BIT NOT NULL CONSTRAINT [DF_Organization_SyncSeats] DEFAULT (0), CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC) ); diff --git a/src/Sql/dbo/Tables/OrganizationDomain.sql b/src/Sql/dbo/Tables/OrganizationDomain.sql index 615dcc1557..582029acfe 100644 --- a/src/Sql/dbo/Tables/OrganizationDomain.sql +++ b/src/Sql/dbo/Tables/OrganizationDomain.sql @@ -25,5 +25,11 @@ GO CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_DomainNameVerifiedDateOrganizationId] ON [dbo].[OrganizationDomain] ([DomainName],[VerifiedDate]) - INCLUDE ([OrganizationId]) + INCLUDE ([OrganizationId]); +GO + +CREATE NONCLUSTERED INDEX [IX_OrganizationDomain_OrganizationId_VerifiedDate] + ON [dbo].[OrganizationDomain] ([OrganizationId], [VerifiedDate]) + INCLUDE ([DomainName]) + WHERE [VerifiedDate] IS NOT NULL; GO diff --git a/src/Sql/dbo/Tables/OrganizationIntegrationConfiguration.sql b/src/Sql/dbo/Tables/OrganizationIntegrationConfiguration.sql index b46ca81141..e15d5576eb 100644 --- a/src/Sql/dbo/Tables/OrganizationIntegrationConfiguration.sql +++ b/src/Sql/dbo/Tables/OrganizationIntegrationConfiguration.sql @@ -2,7 +2,7 @@ CREATE TABLE [dbo].[OrganizationIntegrationConfiguration] ( [Id] UNIQUEIDENTIFIER NOT NULL, [OrganizationIntegrationId] UNIQUEIDENTIFIER NOT NULL, - [EventType] SMALLINT NOT NULL, + [EventType] SMALLINT NULL, [Configuration] VARCHAR (MAX) NULL, [Template] VARCHAR (MAX) NULL, [CreationDate] DATETIME2 (7) NOT NULL, diff --git a/src/Sql/dbo/Tables/OrganizationUser.sql b/src/Sql/dbo/Tables/OrganizationUser.sql index 331e85fe63..a9f228dc3d 100644 --- a/src/Sql/dbo/Tables/OrganizationUser.sql +++ b/src/Sql/dbo/Tables/OrganizationUser.sql @@ -17,13 +17,28 @@ CONSTRAINT [FK_OrganizationUser_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ); - GO CREATE NONCLUSTERED INDEX [IX_OrganizationUser_UserIdOrganizationIdStatusV2] ON [dbo].[OrganizationUser]([UserId] ASC, [OrganizationId] ASC, [Status] ASC); - - GO + CREATE NONCLUSTERED INDEX [IX_OrganizationUser_OrganizationId] ON [dbo].[OrganizationUser]([OrganizationId] ASC); +GO +CREATE NONCLUSTERED INDEX IX_OrganizationUser_EmailOrganizationIdStatus + ON OrganizationUser (Email ASC, OrganizationId ASC, [Status] ASC); +GO + +CREATE NONCLUSTERED INDEX [IX_OrganizationUser_OrganizationId_UserId] + ON [dbo].[OrganizationUser] ([OrganizationId], [UserId]) + INCLUDE ([Email], [Status], [Type], [ExternalId], [CreationDate], + [RevisionDate], [Permissions], [ResetPasswordKey], [AccessSecretsManager]); +GO + +CREATE NONCLUSTERED INDEX [IX_OrganizationUser_UserId_Status_Filtered] + ON [dbo].[OrganizationUser] ([UserId]) + INCLUDE ([Id], [OrganizationId]) + WHERE [Status] = 2; -- Confirmed + +GO diff --git a/src/Sql/dbo/Tables/ProviderOrganization.sql b/src/Sql/dbo/Tables/ProviderOrganization.sql index ccf5455ab3..e6a7dd9270 100644 --- a/src/Sql/dbo/Tables/ProviderOrganization.sql +++ b/src/Sql/dbo/Tables/ProviderOrganization.sql @@ -10,3 +10,7 @@ CONSTRAINT [FK_ProviderOrganization_Provider] FOREIGN KEY ([ProviderId]) REFERENCES [dbo].[Provider] ([Id]) ON DELETE CASCADE, CONSTRAINT [FK_ProviderOrganization_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ); + +GO +CREATE NONCLUSTERED INDEX IX_ProviderOrganization_OrganizationIdProviderId + ON [dbo].[ProviderOrganization] ([OrganizationId], [ProviderId]); diff --git a/src/Sql/dbo/Tables/ProviderUser.sql b/src/Sql/dbo/Tables/ProviderUser.sql index 8905242aa9..b18b4a4afe 100644 --- a/src/Sql/dbo/Tables/ProviderUser.sql +++ b/src/Sql/dbo/Tables/ProviderUser.sql @@ -13,3 +13,8 @@ CONSTRAINT [FK_ProviderUser_Provider] FOREIGN KEY ([ProviderId]) REFERENCES [dbo].[Provider] ([Id]) ON DELETE CASCADE, CONSTRAINT [FK_ProviderUser_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ); + + +GO +CREATE NONCLUSTERED INDEX IX_ProviderUser_UserIdProviderId + ON [dbo].[ProviderUser] ([UserId], [ProviderId]); diff --git a/src/Sql/dbo/Tables/User.sql b/src/Sql/dbo/Tables/User.sql index 188dd4ea3c..239ee67f11 100644 --- a/src/Sql/dbo/Tables/User.sql +++ b/src/Sql/dbo/Tables/User.sql @@ -54,3 +54,7 @@ GO CREATE NONCLUSTERED INDEX [IX_User_Premium_PremiumExpirationDate_RenewalReminderDate] ON [dbo].[User]([Premium] ASC, [PremiumExpirationDate] ASC, [RenewalReminderDate] ASC); +GO +CREATE NONCLUSTERED INDEX [IX_User_Id_EmailDomain] + ON [dbo].[User]([Id] ASC, [Email] ASC); + diff --git a/src/Sql/dbo/Vault/Functions/CipherDetails.sql b/src/Sql/dbo/Vault/Functions/CipherDetails.sql index 5577ff4787..ed92c11cb6 100644 --- a/src/Sql/dbo/Vault/Functions/CipherDetails.sql +++ b/src/Sql/dbo/Vault/Functions/CipherDetails.sql @@ -27,6 +27,7 @@ SELECT END [FolderId], C.[DeletedDate], C.[Reprompt], - C.[Key] + C.[Key], + C.[ArchivedDate] FROM - [dbo].[Cipher] C \ No newline at end of file + [dbo].[Cipher] C diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql index d0e08fcd08..254110f059 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql @@ -17,7 +17,8 @@ @OrganizationUseTotp BIT, -- not used @DeletedDate DATETIME2(7), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -38,7 +39,8 @@ BEGIN [RevisionDate], [DeletedDate], [Reprompt], - [Key] + [Key], + [ArchivedDate] ) VALUES ( @@ -53,7 +55,8 @@ BEGIN @RevisionDate, @DeletedDate, @Reprompt, - @Key + @Key, + @ArchivedDate ) IF @OrganizationId IS NOT NULL diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql index 6e61d3d385..ee7e00b32a 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql @@ -18,14 +18,15 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @CollectionIds AS [dbo].[GuidIdArray] READONLY + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON EXEC [dbo].[CipherDetails_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, @Attachments, @CreationDate, @RevisionDate, @FolderId, @Favorite, @Edit, @ViewPassword, @Manage, - @OrganizationUseTotp, @DeletedDate, @Reprompt, @Key + @OrganizationUseTotp, @DeletedDate, @Reprompt, @Key, @ArchivedDate DECLARE @UpdateCollectionsSuccess INT EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql index 7e2c893a41..2646159b62 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql @@ -20,6 +20,7 @@ SELECT [Reprompt], [Key], [OrganizationUseTotp], + [ArchivedDate], MAX ([Edit]) AS [Edit], MAX ([ViewPassword]) AS [ViewPassword], MAX ([Manage]) AS [Manage] @@ -41,5 +42,6 @@ SELECT [DeletedDate], [Reprompt], [Key], - [OrganizationUseTotp] + [OrganizationUseTotp], + [ArchivedDate] END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql index 8fc95eb302..c17f5761ff 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql @@ -17,7 +17,8 @@ @OrganizationUseTotp BIT, -- not used @DeletedDate DATETIME2(2), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -55,7 +56,8 @@ BEGIN [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate, [DeletedDate] = @DeletedDate, - [Key] = @Key + [Key] = @Key, + [ArchivedDate] = @ArchivedDate WHERE [Id] = @Id diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql new file mode 100644 index 0000000000..68f11c0d4f --- /dev/null +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql @@ -0,0 +1,39 @@ +CREATE PROCEDURE [dbo].[Cipher_Archive] + @Ids AS [dbo].[GuidIdArray] READONLY, + @UserId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #Temp + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL + ) + + INSERT INTO #Temp + SELECT + [Id], + [UserId] + FROM + [dbo].[UserCipherDetails](@UserId) + WHERE + [Edit] = 1 + AND [ArchivedDate] IS NULL + AND [Id] IN (SELECT * FROM @Ids) + + DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); + UPDATE + [dbo].[Cipher] + SET + [ArchivedDate] = @UtcNow, + [RevisionDate] = @UtcNow + WHERE + [Id] IN (SELECT [Id] FROM #Temp) + + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + + DROP TABLE #Temp + + SELECT @UtcNow +END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql index 676c013cc8..eb49136895 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql @@ -11,7 +11,8 @@ @RevisionDate DATETIME2(7), @DeletedDate DATETIME2(7), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -29,7 +30,8 @@ BEGIN [RevisionDate], [DeletedDate], [Reprompt], - [Key] + [Key], + [ArchivedDate] ) VALUES ( @@ -44,7 +46,8 @@ BEGIN @RevisionDate, @DeletedDate, @Reprompt, - @Key + @Key, + @ArchivedDate ) IF @OrganizationId IS NOT NULL diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql index 775ab0e0a0..ac7be1bbae 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql @@ -12,14 +12,15 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @CollectionIds AS [dbo].[GuidIdArray] READONLY + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON EXEC [dbo].[Cipher_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, - @Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key + @Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @ArchivedDate DECLARE @UpdateCollectionsSuccess INT EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds -END \ No newline at end of file +END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql index d2324a1d00..c14a612b0f 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql @@ -1,49 +1,91 @@ CREATE PROCEDURE [dbo].[Cipher_DeleteByOrganizationId] - @OrganizationId AS UNIQUEIDENTIFIER -AS -BEGIN - SET NOCOUNT ON + @OrganizationId UNIQUEIDENTIFIER + AS + BEGIN + SET NOCOUNT ON; - DECLARE @BatchSize INT = 100 + DECLARE @BatchSize INT = 1000; - -- Delete collection ciphers - WHILE @BatchSize > 0 - BEGIN - BEGIN TRANSACTION Cipher_DeleteByOrganizationId_CC + BEGIN TRY + BEGIN TRANSACTION; - DELETE TOP(@BatchSize) CC - FROM - [dbo].[CollectionCipher] CC - INNER JOIN - [dbo].[Collection] C ON C.[Id] = CC.[CollectionId] - WHERE - C.[OrganizationId] = @OrganizationId + --------------------------------------------------------------------- + -- 1. Delete organization ciphers that are NOT in any default + -- user collection (Collection.Type = 1). + --------------------------------------------------------------------- + WHILE 1 = 1 + BEGIN + ;WITH Target AS + ( + SELECT TOP (@BatchSize) C.Id + FROM dbo.Cipher C + WHERE C.OrganizationId = @OrganizationId + AND NOT EXISTS ( + SELECT 1 + FROM dbo.CollectionCipher CC2 + INNER JOIN dbo.Collection Col2 + ON Col2.Id = CC2.CollectionId + AND Col2.Type = 1 -- Default user collection + WHERE CC2.CipherId = C.Id + ) + ORDER BY C.Id -- Deterministic ordering (matches clustered index) + ) + DELETE C + FROM dbo.Cipher C + INNER JOIN Target T ON T.Id = C.Id; - SET @BatchSize = @@ROWCOUNT + IF @@ROWCOUNT = 0 BREAK; + END - COMMIT TRANSACTION Cipher_DeleteByOrganizationId_CC - END + --------------------------------------------------------------------- + -- 2. Remove remaining CollectionCipher rows that reference + -- non-default (Type = 0 / shared) collections, for ciphers + -- that were preserved because they belong to at least one + -- default (Type = 1) collection. + --------------------------------------------------------------------- + SET @BatchSize = 1000; + WHILE 1 = 1 + BEGIN + ;WITH ToDelete AS + ( + SELECT TOP (@BatchSize) + CC.CipherId, + CC.CollectionId + FROM dbo.CollectionCipher CC + INNER JOIN dbo.Collection Col + ON Col.Id = CC.CollectionId + AND Col.Type = 0 -- Non-default collections + INNER JOIN dbo.Cipher C + ON C.Id = CC.CipherId + WHERE C.OrganizationId = @OrganizationId + ORDER BY CC.CollectionId, CC.CipherId -- Matches clustered index + ) + DELETE CC + FROM dbo.CollectionCipher CC + INNER JOIN ToDelete TD + ON CC.CipherId = TD.CipherId + AND CC.CollectionId = TD.CollectionId; - -- Reset batch size - SET @BatchSize = 100 + IF @@ROWCOUNT = 0 BREAK; + END - -- Delete ciphers - WHILE @BatchSize > 0 - BEGIN - BEGIN TRANSACTION Cipher_DeleteByOrganizationId + --------------------------------------------------------------------- + -- 3. Bump revision date (inside transaction for consistency) + --------------------------------------------------------------------- + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId; - DELETE TOP(@BatchSize) - FROM - [dbo].[Cipher] - WHERE - [OrganizationId] = @OrganizationId + COMMIT TRANSACTION ; - SET @BatchSize = @@ROWCOUNT - - COMMIT TRANSACTION Cipher_DeleteByOrganizationId - END - - -- Cleanup organization - EXEC [dbo].[Organization_UpdateStorage] @OrganizationId - EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId -END \ No newline at end of file + --------------------------------------------------------------------- + -- 4. Update storage usage (outside the transaction to avoid + -- holding locks during long-running calculation) + --------------------------------------------------------------------- + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId; + END TRY + BEGIN CATCH + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + THROW; + END CATCH + END + GO diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql new file mode 100644 index 0000000000..c2b7b10619 --- /dev/null +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql @@ -0,0 +1,39 @@ +CREATE PROCEDURE [dbo].[Cipher_Unarchive] + @Ids AS [dbo].[GuidIdArray] READONLY, + @UserId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #Temp + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL + ) + + INSERT INTO #Temp + SELECT + [Id], + [UserId] + FROM + [dbo].[UserCipherDetails](@UserId) + WHERE + [Edit] = 1 + AND [ArchivedDate] IS NOT NULL + AND [Id] IN (SELECT * FROM @Ids) + + DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); + UPDATE + [dbo].[Cipher] + SET + [ArchivedDate] = NULL, + [RevisionDate] = @UtcNow + WHERE + [Id] IN (SELECT [Id] FROM #Temp) + + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + + DROP TABLE #Temp + + SELECT @UtcNow +END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql index 8baf1b5f0f..912badc906 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql @@ -11,7 +11,8 @@ @RevisionDate DATETIME2(7), @DeletedDate DATETIME2(7), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -30,7 +31,8 @@ BEGIN [RevisionDate] = @RevisionDate, [DeletedDate] = @DeletedDate, [Reprompt] = @Reprompt, - [Key] = @Key + [Key] = @Key, + [ArchivedDate] = @ArchivedDate WHERE [Id] = @Id diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql index 0a0c980e4a..55852c4d27 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql @@ -12,7 +12,8 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @CollectionIds AS [dbo].[GuidIdArray] READONLY + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -37,8 +38,7 @@ BEGIN [Data] = @Data, [Attachments] = @Attachments, [RevisionDate] = @RevisionDate, - [DeletedDate] = @DeletedDate, - [Key] = @Key + [DeletedDate] = @DeletedDate, [Key] = @Key, [ArchivedDate] = @ArchivedDate -- No need to update CreationDate, Favorites, Folders, or Type since that data will not change WHERE [Id] = @Id @@ -54,4 +54,4 @@ BEGIN EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId SELECT 0 -- 0 = Success -END \ No newline at end of file +END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql b/src/Sql/dbo/Vault/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql index 267024f56c..bd8d48b29b 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql @@ -66,7 +66,8 @@ BEGIN LEFT JOIN [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId] WHERE - C.[OrganizationId] = @OrganizationId + C.[OrganizationId] = @OrganizationId AND + C.[Type] != 1 -- Exclude DefaultUserCollection GROUP BY C.[Id], C.[OrganizationId], diff --git a/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql index 2a4ecdb4c1..2614135c54 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql @@ -1,56 +1,87 @@ CREATE PROCEDURE [dbo].[SecurityTask_ReadByUserIdStatus] - @UserId UNIQUEIDENTIFIER, - @Status TINYINT = NULL + @UserId [UNIQUEIDENTIFIER], + @Status [TINYINT] = NULL AS BEGIN - SET NOCOUNT ON + SET NOCOUNT ON; + WITH [OrganizationAccess] AS ( + SELECT + [OU].[OrganizationId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [O].[Enabled] = 1 + ), + [UserCollectionAccess] AS ( + SELECT + [CC].[CipherId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + INNER JOIN [dbo].[CollectionUser] [CU] + ON [CU].[OrganizationUserId] = [OU].[Id] + INNER JOIN [dbo].[CollectionCipher] [CC] + ON [CC].[CollectionId] = [CU].[CollectionId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [O].[Enabled] = 1 + AND [CU].[ReadOnly] = 0 + ), + [GroupCollectionAccess] AS ( + SELECT + [CC].[CipherId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + INNER JOIN [dbo].[GroupUser] [GU] + ON [GU].[OrganizationUserId] = [OU].[Id] + INNER JOIN [dbo].[CollectionGroup] [CG] + ON [CG].[GroupId] = [GU].[GroupId] + INNER JOIN [dbo].[CollectionCipher] [CC] + ON [CC].[CollectionId] = [CG].[CollectionId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [CG].[ReadOnly] = 0 + ), + [AccessibleCiphers] AS ( + SELECT + [CipherId] FROM [UserCollectionAccess] + UNION + SELECT + [CipherId] FROM [GroupCollectionAccess] + ) SELECT - ST.Id, - ST.OrganizationId, - ST.CipherId, - ST.Type, - ST.Status, - ST.CreationDate, - ST.RevisionDate + [ST].[Id], + [ST].[OrganizationId], + [ST].[CipherId], + [ST].[Type], + [ST].[Status], + [ST].[CreationDate], + [ST].[RevisionDate] FROM - [dbo].[SecurityTaskView] ST - INNER JOIN - [dbo].[OrganizationUserView] OU ON OU.[OrganizationId] = ST.[OrganizationId] - INNER JOIN - [dbo].[Organization] O ON O.[Id] = ST.[OrganizationId] - LEFT JOIN - [dbo].[CipherView] C ON C.[Id] = ST.[CipherId] - LEFT JOIN - [dbo].[CollectionCipher] CC ON CC.[CipherId] = C.[Id] AND C.[Id] IS NOT NULL - LEFT JOIN - [dbo].[CollectionUser] CU ON CU.[CollectionId] = CC.[CollectionId] AND CU.[OrganizationUserId] = OU.[Id] AND C.[Id] IS NOT NULL - LEFT JOIN - [dbo].[GroupUser] GU ON GU.[OrganizationUserId] = OU.[Id] AND CU.[CollectionId] IS NULL AND C.[Id] IS NOT NULL - LEFT JOIN - [dbo].[CollectionGroup] CG ON CG.[GroupId] = GU.[GroupId] AND CG.[CollectionId] = CC.[CollectionId] + [dbo].[SecurityTaskView] [ST] + INNER JOIN [OrganizationAccess] [OA] + ON [ST].[OrganizationId] = [OA].[OrganizationId] WHERE - OU.[UserId] = @UserId - AND OU.[Status] = 2 -- Ensure user is confirmed - AND O.[Enabled] = 1 + (@Status IS NULL OR [ST].[Status] = @Status) AND ( - ST.[CipherId] IS NULL - OR ( - C.[Id] IS NOT NULL - AND ( - CU.[ReadOnly] = 0 - OR CG.[ReadOnly] = 0 - ) - ) + [ST].[CipherId] IS NULL + OR EXISTS ( + SELECT 1 + FROM [AccessibleCiphers] [AC] + WHERE [AC].[CipherId] = [ST].[CipherId] + ) ) - AND ST.[Status] = COALESCE(@Status, ST.[Status]) - GROUP BY - ST.Id, - ST.OrganizationId, - ST.CipherId, - ST.Type, - ST.Status, - ST.CreationDate, - ST.RevisionDate - ORDER BY ST.[CreationDate] DESC + ORDER BY + [ST].[CreationDate] DESC + OPTION (RECOMPILE); END diff --git a/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadMetricsByOrganizationId.sql b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadMetricsByOrganizationId.sql new file mode 100644 index 0000000000..0d9d076a98 --- /dev/null +++ b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadMetricsByOrganizationId.sql @@ -0,0 +1,14 @@ +CREATE PROCEDURE [dbo].[SecurityTask_ReadMetricsByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + COUNT(CASE WHEN st.[Status] = 1 THEN 1 END) AS CompletedTasks, + COUNT(*) AS TotalTasks + FROM + [dbo].[SecurityTaskView] st + WHERE + st.[OrganizationId] = @OrganizationId +END diff --git a/src/Sql/dbo/Vault/Tables/Cipher.sql b/src/Sql/dbo/Vault/Tables/Cipher.sql index 5ecff19e70..d69035a0a9 100644 --- a/src/Sql/dbo/Vault/Tables/Cipher.sql +++ b/src/Sql/dbo/Vault/Tables/Cipher.sql @@ -13,6 +13,7 @@ CREATE TABLE [dbo].[Cipher] ( [DeletedDate] DATETIME2 (7) NULL, [Reprompt] TINYINT NULL, [Key] VARCHAR(MAX) NULL, + [ArchivedDate] DATETIME2 (7) NULL, CONSTRAINT [PK_Cipher] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [FK_Cipher_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]), CONSTRAINT [FK_Cipher_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) @@ -34,3 +35,5 @@ GO CREATE NONCLUSTERED INDEX [IX_Cipher_DeletedDate] ON [dbo].[Cipher]([DeletedDate] ASC); + +GO diff --git a/src/Sql/dbo/Vault/Tables/SecurityTask.sql b/src/Sql/dbo/Vault/Tables/SecurityTask.sql index a00dcede9c..dbf9827a63 100644 --- a/src/Sql/dbo/Vault/Tables/SecurityTask.sql +++ b/src/Sql/dbo/Vault/Tables/SecurityTask.sql @@ -19,3 +19,6 @@ CREATE NONCLUSTERED INDEX [IX_SecurityTask_CipherId] GO CREATE NONCLUSTERED INDEX [IX_SecurityTask_OrganizationId] ON [dbo].[SecurityTask]([OrganizationId] ASC) WHERE OrganizationId IS NOT NULL; + +GO + diff --git a/src/Sql/dbo/Views/OrganizationCipherDetailsCollectionsView.sql b/src/Sql/dbo/Views/OrganizationCipherDetailsCollectionsView.sql new file mode 100644 index 0000000000..66bb38fe10 --- /dev/null +++ b/src/Sql/dbo/Views/OrganizationCipherDetailsCollectionsView.sql @@ -0,0 +1,28 @@ +CREATE VIEW [dbo].[OrganizationCipherDetailsCollectionsView] +AS + SELECT + C.[Id], + C.[UserId], + C.[OrganizationId], + C.[Type], + C.[Data], + C.[Attachments], + C.[Favorites], + C.[Folders], + C.[CreationDate], + C.[RevisionDate], + C.[DeletedDate], + C.[Reprompt], + C.[Key], + CASE + WHEN O.[UseTotp] = 1 THEN 1 + ELSE 0 + END AS [OrganizationUseTotp], + CC.[CollectionId], + COL.[Type] AS [CollectionType] + FROM [dbo].[Cipher] C + INNER JOIN [dbo].[Organization] O ON C.[OrganizationId] = O.[Id] + LEFT JOIN [dbo].[CollectionCipher] CC ON CC.[CipherId] = C.[Id] + LEFT JOIN [dbo].[Collection] COL ON CC.[CollectionId] = COL.[Id] + WHERE C.[UserId] IS NULL -- Organization ciphers only + AND O.[Enabled] = 1; -- Only enabled organizations diff --git a/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql b/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql index b032bd5a81..ba7e765569 100644 --- a/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql +++ b/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql @@ -37,6 +37,7 @@ SELECT PO.[ProviderId], P.[Name] ProviderName, P.[Type] ProviderType, + SS.[Enabled] SsoEnabled, SS.[Data] SsoConfig, OS.[FriendlyName] FamilySponsorshipFriendlyName, OS.[LastSyncDate] FamilySponsorshipLastSyncDate, diff --git a/src/Sql/dbo/Views/UserEmailDomainView.sql b/src/Sql/dbo/Views/UserEmailDomainView.sql new file mode 100644 index 0000000000..84930a41f1 --- /dev/null +++ b/src/Sql/dbo/Views/UserEmailDomainView.sql @@ -0,0 +1,10 @@ +CREATE VIEW [dbo].[UserEmailDomainView] +AS +SELECT + Id, + Email, + SUBSTRING(Email, CHARINDEX('@', Email) + 1, LEN(Email)) AS EmailDomain +FROM dbo.[User] +WHERE Email IS NOT NULL + AND CHARINDEX('@', Email) > 0 +GO diff --git a/src/Sql/dbo_finalization/.gitkeep b/src/Sql/dbo_finalization/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs new file mode 100644 index 0000000000..7c61a88bd8 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs @@ -0,0 +1,477 @@ +using System.Net; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Request; +using Bit.Api.Models.Response; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.Services; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationUserControllerTests : IClassFixture, IAsyncLifetime +{ + private static readonly string _mockEncryptedString = + "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + + public OrganizationUserControllerTests(ApiApplicationFactory apiFactory) + { + _factory = apiFactory; + _factory.SubstituteService(featureService => + { + featureService + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(true); + }); + _client = _factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private Organization _organization = null!; + private string _ownerEmail = null!; + + [Fact] + public async Task BulkDeleteAccount_Success() + { + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Owner); + + await _loginHelper.LoginAsync(userEmail); + + var (_, orgUserToDelete) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + await OrganizationTestHelpers.CreateVerifiedDomainAsync(_factory, _organization.Id, "bitwarden.com"); + + var userRepository = _factory.GetService(); + var organizationUserRepository = _factory.GetService(); + + Assert.NotNull(orgUserToDelete.UserId); + Assert.NotNull(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); + Assert.NotNull(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [orgUserToDelete.Id] + }; + + var httpResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/delete-account", request); + var content = await httpResponse.Content.ReadFromJsonAsync>(); + Assert.Single(content.Data, r => r.Id == orgUserToDelete.Id && r.Error == string.Empty); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Null(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); + Assert.Null(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); + } + + [Fact] + public async Task BulkDeleteAccount_MixedResults() + { + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Admin); + + await _loginHelper.LoginAsync(userEmail); + + // Can delete users + var (_, validOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + // Cannot delete owners + var (_, invalidOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.Owner); + await OrganizationTestHelpers.CreateVerifiedDomainAsync(_factory, _organization.Id, "bitwarden.com"); + + var userRepository = _factory.GetService(); + var organizationUserRepository = _factory.GetService(); + + Assert.NotNull(validOrgUser.UserId); + Assert.NotNull(invalidOrgUser.UserId); + + var arrangedUsers = + await userRepository.GetManyAsync([validOrgUser.UserId.Value, invalidOrgUser.UserId.Value]); + Assert.Equal(2, arrangedUsers.Count()); + + var arrangedOrgUsers = + await organizationUserRepository.GetManyAsync([validOrgUser.Id, invalidOrgUser.Id]); + Assert.Equal(2, arrangedOrgUsers.Count); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [validOrgUser.Id, invalidOrgUser.Id] + }; + + var httpResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/delete-account", request); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + var debug = await httpResponse.Content.ReadAsStringAsync(); + var content = await httpResponse.Content.ReadFromJsonAsync>(); + Assert.Equal(2, content.Data.Count()); + Assert.Contains(content.Data, r => r.Id == validOrgUser.Id && r.Error == string.Empty); + Assert.Contains(content.Data, r => + r.Id == invalidOrgUser.Id && + string.Equals(r.Error, new CannotDeleteOwnersError().Message, StringComparison.Ordinal)); + + var actualUsers = + await userRepository.GetManyAsync([validOrgUser.UserId.Value, invalidOrgUser.UserId.Value]); + Assert.Single(actualUsers, u => u.Id == invalidOrgUser.UserId.Value); + + var actualOrgUsers = + await organizationUserRepository.GetManyAsync([validOrgUser.Id, invalidOrgUser.Id]); + Assert.Single(actualOrgUsers, ou => ou.Id == invalidOrgUser.Id); + } + + [Theory] + [InlineData(OrganizationUserType.User)] + [InlineData(OrganizationUserType.Custom)] + public async Task BulkDeleteAccount_WhenUserCannotManageUsers_ReturnsForbiddenResponse(OrganizationUserType organizationUserType) + { + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, organizationUserType, new Permissions { ManageUsers = false }); + + await _loginHelper.LoginAsync(userEmail); + + var request = new OrganizationUserBulkRequestModel + { + Ids = new List { Guid.NewGuid() } + }; + + var httpResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/delete-account", request); + + Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode); + } + + [Fact] + public async Task DeleteAccount_Success() + { + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Owner); + + await _loginHelper.LoginAsync(userEmail); + + var (_, orgUserToDelete) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + await OrganizationTestHelpers.CreateVerifiedDomainAsync(_factory, _organization.Id, "bitwarden.com"); + + var userRepository = _factory.GetService(); + var organizationUserRepository = _factory.GetService(); + + Assert.NotNull(orgUserToDelete.UserId); + Assert.NotNull(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); + Assert.NotNull(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); + + var httpResponse = await _client.DeleteAsync($"organizations/{_organization.Id}/users/{orgUserToDelete.Id}/delete-account"); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Null(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); + Assert.Null(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); + } + + [Theory] + [InlineData(OrganizationUserType.User)] + [InlineData(OrganizationUserType.Custom)] + public async Task DeleteAccount_WhenUserCannotManageUsers_ReturnsForbiddenResponse(OrganizationUserType organizationUserType) + { + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, organizationUserType, new Permissions { ManageUsers = false }); + + await _loginHelper.LoginAsync(userEmail); + + var userToRemove = Guid.NewGuid(); + + var httpResponse = await _client.DeleteAsync($"organizations/{_organization.Id}/users/{userToRemove}/delete-account"); + + Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode); + } + + [Theory] + [InlineData(OrganizationUserType.User)] + [InlineData(OrganizationUserType.Custom)] + public async Task GetAccountRecoveryDetails_WithoutManageResetPasswordPermission_ReturnsForbiddenResponse(OrganizationUserType organizationUserType) + { + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, organizationUserType, new Permissions { ManageUsers = false }); + + await _loginHelper.LoginAsync(userEmail); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [] + }; + + var httpResponse = + await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/account-recovery-details", request); + + Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"org-user-integration-test-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023, + ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + } + + [Fact] + public async Task Confirm_WithValidUser_ReturnsSuccess() + { + await OrganizationTestHelpers.EnableOrganizationDataOwnershipPolicyAsync(_factory, _organization.Id); + + var acceptedOrgUser = (await CreateAcceptedUsersAsync(new[] { ("test1@bitwarden.com", OrganizationUserType.User) })).First(); + + await _loginHelper.LoginAsync(_ownerEmail); + + var confirmModel = new OrganizationUserConfirmRequestModel + { + Key = "test-key", + DefaultUserCollectionName = _mockEncryptedString + }; + var confirmResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/{acceptedOrgUser.Id}/confirm", confirmModel); + + Assert.Equal(HttpStatusCode.OK, confirmResponse.StatusCode); + + await VerifyUserConfirmedAsync(acceptedOrgUser, "test-key"); + await VerifyDefaultCollectionCountAsync(acceptedOrgUser, 1); + } + + [Fact] + public async Task Confirm_WithValidOwner_ReturnsSuccess() + { + await OrganizationTestHelpers.EnableOrganizationDataOwnershipPolicyAsync(_factory, _organization.Id); + + var acceptedOrgUser = (await CreateAcceptedUsersAsync(new[] { ("owner1@bitwarden.com", OrganizationUserType.Owner) })).First(); + + await _loginHelper.LoginAsync(_ownerEmail); + + var confirmModel = new OrganizationUserConfirmRequestModel + { + Key = "test-key", + DefaultUserCollectionName = _mockEncryptedString + }; + var confirmResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/{acceptedOrgUser.Id}/confirm", confirmModel); + + Assert.Equal(HttpStatusCode.OK, confirmResponse.StatusCode); + + await VerifyUserConfirmedAsync(acceptedOrgUser, "test-key"); + await VerifyDefaultCollectionCountAsync(acceptedOrgUser, 0); + } + + [Fact] + public async Task BulkConfirm_WithValidUsers_ReturnsSuccess() + { + const string testKeyFormat = "test-key-{0}"; + await OrganizationTestHelpers.EnableOrganizationDataOwnershipPolicyAsync(_factory, _organization.Id); + + var acceptedUsers = await CreateAcceptedUsersAsync([ + ("test1@example.com", OrganizationUserType.User), + ("test2@example.com", OrganizationUserType.Owner), + ("test3@example.com", OrganizationUserType.User) + ]); + + await _loginHelper.LoginAsync(_ownerEmail); + + var bulkConfirmModel = new OrganizationUserBulkConfirmRequestModel + { + Keys = acceptedUsers.Select((organizationUser, index) => new OrganizationUserBulkConfirmRequestModelEntry + { + Id = organizationUser.Id, + Key = string.Format(testKeyFormat, index) + }), + DefaultUserCollectionName = _mockEncryptedString + }; + + var bulkConfirmResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/confirm", bulkConfirmModel); + + Assert.Equal(HttpStatusCode.OK, bulkConfirmResponse.StatusCode); + + await VerifyMultipleUsersConfirmedAsync(acceptedUsers.Select((organizationUser, index) => + (organizationUser, string.Format(testKeyFormat, index))).ToList()); + await VerifyDefaultCollectionCountAsync(acceptedUsers.ElementAt(0), 1); + await VerifyDefaultCollectionCountAsync(acceptedUsers.ElementAt(1), 0); // Owner does not get a default collection + await VerifyDefaultCollectionCountAsync(acceptedUsers.ElementAt(2), 1); + } + + [Fact] + public async Task Put_WithExistingDefaultCollection_Success() + { + // Arrange + await _loginHelper.LoginAsync(_ownerEmail); + + var (userEmail, organizationUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.User); + + var (group, sharedCollection, defaultCollection) = await CreateTestDataAsync(); + await AssignDefaultCollectionToUserAsync(organizationUser, defaultCollection); + + // Act + var updateRequest = CreateUpdateRequest(sharedCollection, group); + var httpResponse = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/{organizationUser.Id}", updateRequest); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + + // Assert + await VerifyUserWasUpdatedCorrectlyAsync(organizationUser, expectedType: OrganizationUserType.Custom, expectedManageGroups: true); + await VerifyGroupAccessWasAddedAsync(organizationUser, [group]); + await VerifyCollectionAccessWasUpdatedCorrectlyAsync(organizationUser, sharedCollection.Id, defaultCollection.Id); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + private async Task<(Group group, Collection sharedCollection, Collection defaultCollection)> CreateTestDataAsync() + { + var groupRepository = _factory.GetService(); + var group = await groupRepository.CreateAsync(new Group + { + OrganizationId = _organization.Id, + Name = $"Test Group {Guid.NewGuid()}" + }); + + var collectionRepository = _factory.GetService(); + var sharedCollection = await collectionRepository.CreateAsync(new Collection + { + OrganizationId = _organization.Id, + Name = $"Test Collection {Guid.NewGuid()}", + Type = CollectionType.SharedCollection + }); + + var defaultCollection = await collectionRepository.CreateAsync(new Collection + { + OrganizationId = _organization.Id, + Name = $"My Items {Guid.NewGuid()}", + Type = CollectionType.DefaultUserCollection + }); + + return (group, sharedCollection, defaultCollection); + } + + private async Task AssignDefaultCollectionToUserAsync(OrganizationUser organizationUser, Collection defaultCollection) + { + var organizationUserRepository = _factory.GetService(); + await organizationUserRepository.ReplaceAsync(organizationUser, + new List + { + new CollectionAccessSelection + { + Id = defaultCollection.Id, + ReadOnly = false, + HidePasswords = false, + Manage = true + } + }); + } + + private static OrganizationUserUpdateRequestModel CreateUpdateRequest(Collection sharedCollection, Group group) + { + return new OrganizationUserUpdateRequestModel + { + Type = OrganizationUserType.Custom, + Permissions = new Permissions + { + ManageGroups = true + }, + Collections = new List + { + new SelectionReadOnlyRequestModel + { + Id = sharedCollection.Id, + ReadOnly = true, + HidePasswords = false, + Manage = false + } + }, + Groups = new List { group.Id } + }; + } + + private async Task VerifyUserWasUpdatedCorrectlyAsync( + OrganizationUser organizationUser, + OrganizationUserType expectedType, + bool expectedManageGroups) + { + var organizationUserRepository = _factory.GetService(); + var updatedOrgUser = await organizationUserRepository.GetByIdAsync(organizationUser.Id); + Assert.NotNull(updatedOrgUser); + Assert.Equal(expectedType, updatedOrgUser.Type); + Assert.Equal(expectedManageGroups, updatedOrgUser.GetPermissions().ManageGroups); + } + + private async Task VerifyGroupAccessWasAddedAsync( + OrganizationUser organizationUser, IEnumerable groups) + { + var groupRepository = _factory.GetService(); + var userGroups = await groupRepository.GetManyIdsByUserIdAsync(organizationUser.Id); + Assert.All(groups, group => Assert.Contains(group.Id, userGroups)); + } + + private async Task VerifyCollectionAccessWasUpdatedCorrectlyAsync( + OrganizationUser organizationUser, Guid sharedCollectionId, Guid defaultCollectionId) + { + var organizationUserRepository = _factory.GetService(); + var (_, collectionAccess) = await organizationUserRepository.GetByIdWithCollectionsAsync(organizationUser.Id); + var collectionIds = collectionAccess.Select(c => c.Id).ToHashSet(); + + Assert.Contains(defaultCollectionId, collectionIds); + Assert.Contains(sharedCollectionId, collectionIds); + + var newCollectionAccess = collectionAccess.First(c => c.Id == sharedCollectionId); + Assert.True(newCollectionAccess.ReadOnly); + Assert.False(newCollectionAccess.HidePasswords); + Assert.False(newCollectionAccess.Manage); + } + + private async Task> CreateAcceptedUsersAsync( + IEnumerable<(string email, OrganizationUserType userType)> newUsers) + { + var acceptedUsers = new List(); + + foreach (var (email, userType) in newUsers) + { + await _factory.LoginWithNewAccount(email); + + var acceptedOrgUser = await OrganizationTestHelpers.CreateUserAsync( + _factory, _organization.Id, email, + userType, userStatusType: OrganizationUserStatusType.Accepted); + + acceptedUsers.Add(acceptedOrgUser); + } + + return acceptedUsers; + } + + private async Task VerifyDefaultCollectionCountAsync(OrganizationUser orgUser, int expectedCount) + { + var collectionRepository = _factory.GetService(); + var collections = await collectionRepository.GetManyByUserIdAsync(orgUser.UserId!.Value); + Assert.Equal(expectedCount, collections.Count); + } + + private async Task VerifyUserConfirmedAsync(OrganizationUser orgUser, string expectedKey) + { + await VerifyMultipleUsersConfirmedAsync(new List<(OrganizationUser orgUser, string key)> { (orgUser, expectedKey) }); + } + + private async Task VerifyMultipleUsersConfirmedAsync(List<(OrganizationUser orgUser, string key)> acceptedOrganizationUsers) + { + var orgUserRepository = _factory.GetService(); + for (int i = 0; i < acceptedOrganizationUsers.Count; i++) + { + var confirmedUser = await orgUserRepository.GetByIdAsync(acceptedOrganizationUsers[i].orgUser.Id); + Assert.Equal(OrganizationUserStatusType.Confirmed, confirmedUser.Status); + Assert.Equal(acceptedOrganizationUsers[i].key, confirmedUser.Key); + } + } +} diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs new file mode 100644 index 0000000000..1efc2f843d --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs @@ -0,0 +1,214 @@ +using System.Net; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class PoliciesControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private Organization _organization = null!; + private string _ownerEmail = null!; + + public PoliciesControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _factory.SubstituteService(featureService => + { + featureService + .IsEnabled("pm-19467-create-default-location") + .Returns(true); + }); + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + await _loginHelper.LoginAsync(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task PutVNext_OrganizationDataOwnershipPolicy_Success() + { + // Arrange + const PolicyType policyType = PolicyType.OrganizationDataOwnership; + + const string defaultCollectionName = "Test Default Collection"; + var request = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + }, + Metadata = new Dictionary + { + { "defaultUserCollectionName", defaultCollectionName } + } + }; + + var (_, admin) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Admin); + + var (_, user) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.User); + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", + JsonContent.Create(request)); + + // Assert + await AssertResponse(); + + await AssertPolicy(); + + await AssertDefaultCollectionCreatedOnlyForUserTypeAsync(); + return; + + async Task AssertDefaultCollectionCreatedOnlyForUserTypeAsync() + { + var collectionRepository = _factory.GetService(); + await AssertUserExpectations(collectionRepository); + await AssertAdminExpectations(collectionRepository); + } + + async Task AssertUserExpectations(ICollectionRepository collectionRepository) + { + var collections = await collectionRepository.GetManyByUserIdAsync(user.UserId!.Value); + var defaultCollection = collections.FirstOrDefault(c => c.Name == defaultCollectionName); + Assert.NotNull(defaultCollection); + Assert.Equal(_organization.Id, defaultCollection.OrganizationId); + } + + async Task AssertAdminExpectations(ICollectionRepository collectionRepository) + { + var collections = await collectionRepository.GetManyByUserIdAsync(admin.UserId!.Value); + var defaultCollection = collections.FirstOrDefault(c => c.Name == defaultCollectionName); + Assert.Null(defaultCollection); + } + + async Task AssertResponse() + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadFromJsonAsync(); + + Assert.True(content.Enabled); + Assert.Equal(policyType, content.Type); + Assert.Equal(_organization.Id, content.OrganizationId); + } + + async Task AssertPolicy() + { + var policyRepository = _factory.GetService(); + var policy = await policyRepository.GetByOrganizationIdTypeAsync(_organization.Id, policyType); + + Assert.NotNull(policy); + Assert.True(policy.Enabled); + Assert.Equal(policyType, policy.Type); + Assert.Null(policy.Data); + Assert.Equal(_organization.Id, policy.OrganizationId); + } + } + + [Fact] + public async Task PutVNext_MasterPasswordPolicy_Success() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + Data = new Dictionary + { + { "minComplexity", 10 }, + { "minLength", 12 }, + { "requireUpper", true }, + { "requireLower", false }, + { "requireNumbers", true }, + { "requireSpecial", false }, + { "enforceOnLogin", true } + } + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", + JsonContent.Create(request)); + + // Assert + await AssertResponse(); + + await AssertPolicyDataForMasterPasswordPolicy(); + return; + + async Task AssertPolicyDataForMasterPasswordPolicy() + { + var policyRepository = _factory.GetService(); + var policy = await policyRepository.GetByOrganizationIdTypeAsync(_organization.Id, policyType); + + AssertPolicy(policy); + AssertMasterPasswordPolicyData(policy); + } + + async Task AssertResponse() + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadFromJsonAsync(); + + Assert.True(content.Enabled); + Assert.Equal(policyType, content.Type); + Assert.Equal(_organization.Id, content.OrganizationId); + } + + void AssertPolicy(Policy policy) + { + Assert.NotNull(policy); + Assert.True(policy.Enabled); + Assert.Equal(policyType, policy.Type); + Assert.Equal(_organization.Id, policy.OrganizationId); + Assert.NotNull(policy.Data); + } + + void AssertMasterPasswordPolicyData(Policy policy) + { + var resultData = policy.GetDataModel(); + + var json = JsonSerializer.Serialize(request.Policy.Data); + var expectedData = JsonSerializer.Deserialize(json); + AssertHelper.AssertPropertyEqual(resultData, expectedData); + } + } + +} diff --git a/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs b/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs new file mode 100644 index 0000000000..32c7f75a2b --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs @@ -0,0 +1,331 @@ +using System.Net; +using Bit.Api.AdminConsole.Public.Models.Request; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Import; + +public class ImportOrganizationUsersAndGroupsCommandTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + private Organization _organization = null!; + private string _ownerEmail = null!; + + public ImportOrganizationUsersAndGroupsCommandTests(ApiApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + // Create the owner account + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + // Create the organization + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023, + ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + // Authorize with the organization api key + await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Import_Existing_Organization_User_Succeeds() + { + var (email, ou) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.User); + + var externalId = Guid.NewGuid().ToString(); + var request = new OrganizationImportRequestModel(); + request.LargeImport = false; + request.OverwriteExisting = false; + request.Groups = []; + request.Members = [ + new OrganizationImportRequestModel.OrganizationImportMemberRequestModel + { + Email = email, + ExternalId = externalId, + Deleted = false + } + ]; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Assert against the database values + var organizationUserRepository = _factory.GetService(); + var orgUser = await organizationUserRepository.GetByIdAsync(ou.Id); + + Assert.NotNull(orgUser); + Assert.Equal(ou.Id, orgUser.Id); + Assert.Equal(email, orgUser.Email); + Assert.Equal(OrganizationUserType.User, orgUser.Type); + Assert.Equal(externalId, orgUser.ExternalId); + Assert.Equal(OrganizationUserStatusType.Confirmed, orgUser.Status); + Assert.Equal(_organization.Id, orgUser.OrganizationId); + + } + + [Fact] + public async Task Import_New_Organization_User_Succeeds() + { + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + + var externalId = Guid.NewGuid().ToString(); + var request = new OrganizationImportRequestModel(); + request.LargeImport = false; + request.OverwriteExisting = false; + request.Groups = []; + request.Members = [ + new OrganizationImportRequestModel.OrganizationImportMemberRequestModel + { + Email = email, + ExternalId = externalId, + Deleted = false + } + ]; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Assert against the database values + var organizationUserRepository = _factory.GetService(); + var orgUser = await organizationUserRepository.GetByOrganizationEmailAsync(_organization.Id, email); + + Assert.NotNull(orgUser); + Assert.Equal(email, orgUser.Email); + Assert.Equal(OrganizationUserType.User, orgUser.Type); + Assert.Equal(externalId, orgUser.ExternalId); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.Equal(_organization.Id, orgUser.OrganizationId); + } + + [Fact] + public async Task Import_New_And_Existing_Organization_Users_Succeeds() + { + // Existing organization user + var (existingEmail, ou) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, + OrganizationUserType.User); + var existingExternalId = Guid.NewGuid().ToString(); + + // New organization user + var newEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(newEmail); + var newExternalId = Guid.NewGuid().ToString(); + + var request = new OrganizationImportRequestModel(); + request.LargeImport = false; + request.OverwriteExisting = false; + request.Groups = []; + request.Members = [ + new OrganizationImportRequestModel.OrganizationImportMemberRequestModel + { + Email = existingEmail, + ExternalId = existingExternalId, + Deleted = false + }, + new OrganizationImportRequestModel.OrganizationImportMemberRequestModel + { + Email = newEmail, + ExternalId = newExternalId, + Deleted = false + } + ]; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Assert against the database values + var organizationUserRepository = _factory.GetService(); + + // Existing user + var existingOrgUser = await organizationUserRepository.GetByIdAsync(ou.Id); + Assert.NotNull(existingOrgUser); + Assert.Equal(existingEmail, existingOrgUser.Email); + Assert.Equal(OrganizationUserType.User, existingOrgUser.Type); + Assert.Equal(existingExternalId, existingOrgUser.ExternalId); + Assert.Equal(OrganizationUserStatusType.Confirmed, existingOrgUser.Status); + Assert.Equal(_organization.Id, existingOrgUser.OrganizationId); + + // New User + var newOrgUser = await organizationUserRepository.GetByOrganizationEmailAsync(_organization.Id, newEmail); + Assert.NotNull(newOrgUser); + Assert.Equal(newEmail, newOrgUser.Email); + Assert.Equal(OrganizationUserType.User, newOrgUser.Type); + Assert.Equal(newExternalId, newOrgUser.ExternalId); + Assert.Equal(OrganizationUserStatusType.Invited, newOrgUser.Status); + Assert.Equal(_organization.Id, newOrgUser.OrganizationId); + } + + [Fact] + public async Task Import_Existing_Groups_Succeeds() + { + var organizationUserRepository = _factory.GetService(); + var group = await OrganizationTestHelpers.CreateGroup(_factory, _organization.Id); + var request = new OrganizationImportRequestModel(); + var addedMember = new OrganizationImportRequestModel.OrganizationImportMemberRequestModel + { + Email = "test@test.com", + ExternalId = "bwtest-externalId", + Deleted = false + }; + + request.LargeImport = false; + request.OverwriteExisting = false; + request.Groups = [ + new OrganizationImportRequestModel.OrganizationImportGroupRequestModel + { + Name = "new-name", + ExternalId = "bwtest-externalId", + MemberExternalIds = [] + } + ]; + request.Members = [addedMember]; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Assert against the database values + var groupRepository = _factory.GetService(); + var existingGroups = (await groupRepository.GetManyByOrganizationIdAsync(_organization.Id)).ToArray(); + + // Assert that we are actually updating the existing group, not adding a new one. + Assert.Single(existingGroups); + Assert.NotNull(existingGroups[0]); + Assert.Equal(group.Id, existingGroups[0].Id); + Assert.Equal("new-name", existingGroups[0].Name); + Assert.Equal(group.ExternalId, existingGroups[0].ExternalId); + + var addedOrgUser = await organizationUserRepository.GetByOrganizationEmailAsync(_organization.Id, addedMember.Email); + Assert.NotNull(addedOrgUser); + } + + [Fact] + public async Task Import_New_Groups_Succeeds() + { + var group = new Group + { + OrganizationId = _organization.Id, + ExternalId = new Guid().ToString(), + Name = "bwtest1" + }; + + var request = new OrganizationImportRequestModel(); + request.LargeImport = false; + request.OverwriteExisting = false; + request.Groups = [ + new OrganizationImportRequestModel.OrganizationImportGroupRequestModel + { + Name = group.Name, + ExternalId = group.ExternalId, + MemberExternalIds = [] + } + ]; + request.Members = []; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Assert against the database values + var groupRepository = _factory.GetService(); + var existingGroups = await groupRepository.GetManyByOrganizationIdAsync(_organization.Id); + var existingGroup = existingGroups.Where(g => g.ExternalId == group.ExternalId).FirstOrDefault(); + + Assert.NotNull(existingGroup); + Assert.Equal(existingGroup.Name, group.Name); + Assert.Equal(existingGroup.ExternalId, group.ExternalId); + } + + [Fact] + public async Task Import_New_And_Existing_Groups_Succeeds() + { + var existingGroup = await OrganizationTestHelpers.CreateGroup(_factory, _organization.Id); + + var newGroup = new Group + { + OrganizationId = _organization.Id, + ExternalId = "test", + Name = "bwtest1" + }; + + var request = new OrganizationImportRequestModel(); + request.LargeImport = false; + request.OverwriteExisting = false; + request.Groups = [ + new OrganizationImportRequestModel.OrganizationImportGroupRequestModel + { + Name = "new-name", + ExternalId = existingGroup.ExternalId, + MemberExternalIds = [] + }, + new OrganizationImportRequestModel.OrganizationImportGroupRequestModel + { + Name = newGroup.Name, + ExternalId = newGroup.ExternalId, + MemberExternalIds = [] + } + ]; + request.Members = []; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Assert against the database values + var groupRepository = _factory.GetService(); + var groups = await groupRepository.GetManyByOrganizationIdAsync(_organization.Id); + + var newGroupInDb = groups.Where(g => g.ExternalId == newGroup.ExternalId).FirstOrDefault(); + Assert.NotNull(newGroupInDb); + Assert.Equal(newGroupInDb.Name, newGroup.Name); + Assert.Equal(newGroupInDb.ExternalId, newGroup.ExternalId); + + var existingGroupInDb = groups.Where(g => g.ExternalId == existingGroup.ExternalId).FirstOrDefault(); + Assert.NotNull(existingGroupInDb); + Assert.Equal(existingGroup.Id, existingGroupInDb.Id); + Assert.Equal("new-name", existingGroupInDb.Name); + Assert.Equal(existingGroup.ExternalId, existingGroupInDb.ExternalId); + } + + [Fact] + public async Task Import_Remove_Member_Without_Master_Password_Throws_400_Error() + { + // ARRANGE: a member without a master password + await OrganizationTestHelpers.CreateUserWithoutMasterPasswordAsync(_factory, Guid.NewGuid() + "@example.com", + _organization.Id); + + // ACT: an import request that would remove that member + var request = new OrganizationImportRequestModel + { + LargeImport = false, + OverwriteExisting = true, // removes all members not in the request + Groups = [], + Members = [] + }; + + var response = await _client.PostAsync($"/public/organization/import", JsonContent.Create(request)); + + // ASSERT: that a 400 error is thrown with the correct error message + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var responseContent = await response.Content.ReadAsStringAsync(); + Assert.Contains("Sync failed. To proceed, disable the 'Remove and re-add users during next sync' setting and try again.", responseContent); + } +} diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index f2bc9f4bac..3cd73c4b1c 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -1,7 +1,9 @@ using System.Diagnostics; using Bit.Api.IntegrationTest.Factories; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -60,7 +62,8 @@ public static class OrganizationTestHelpers OrganizationUserType type, bool accessSecretsManager = false, Permissions? permissions = null, - OrganizationUserStatusType userStatusType = OrganizationUserStatusType.Confirmed + OrganizationUserStatusType userStatusType = OrganizationUserStatusType.Confirmed, + string? externalId = null ) where T : class { var userRepository = factory.GetService(); @@ -76,8 +79,9 @@ public static class OrganizationTestHelpers Key = null, Type = type, Status = userStatusType, - ExternalId = null, + ExternalId = externalId, AccessSecretsManager = accessSecretsManager, + Email = userEmail }; if (permissions != null) @@ -107,7 +111,7 @@ public static class OrganizationTestHelpers await factory.LoginWithNewAccount(email); // Create organizationUser - var organizationUser = await OrganizationTestHelpers.CreateUserAsync(factory, organizationId, email, userType, + var organizationUser = await CreateUserAsync(factory, organizationId, email, userType, permissions: permissions); return (email, organizationUser); @@ -130,4 +134,62 @@ public static class OrganizationTestHelpers await organizationDomainRepository.CreateAsync(verifiedDomain); } + + public static async Task CreateGroup(ApiApplicationFactory factory, Guid organizationId) + { + + var groupRepository = factory.GetService(); + var group = new Group + { + OrganizationId = organizationId, + Id = new Guid(), + ExternalId = "bwtest-externalId", + Name = "bwtest" + }; + + await groupRepository.CreateAsync(group, new List()); + return group; + } + + /// + /// Enables the Organization Data Ownership policy for the specified organization. + /// + public static async Task EnableOrganizationDataOwnershipPolicyAsync( + WebApplicationFactoryBase factory, + Guid organizationId) where T : class + { + var policyRepository = factory.GetService(); + + var policy = new Policy + { + OrganizationId = organizationId, + Type = PolicyType.OrganizationDataOwnership, + Enabled = true + }; + + await policyRepository.CreateAsync(policy); + } + + /// + /// Creates a user account without a Master Password and adds them as a member to the specified organization. + /// + public static async Task<(User User, OrganizationUser OrganizationUser)> CreateUserWithoutMasterPasswordAsync(ApiApplicationFactory factory, string email, Guid organizationId) + { + var userRepository = factory.GetService(); + var user = await userRepository.CreateAsync(new User + { + Email = email, + Culture = "en-US", + SecurityStamp = "D7ZH62BWAZ5R5CASKULCDDIQGKDA2EJ6", + PublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwMj7W00xS7H0NWasGn7PfEq8VfH3fa5XuZucsKxLLRAHHZk0xGRZJH2lFIznizv3GpF8vzhHhe9VpmMkrdIa5oWhwHpy+D7Z1QCQxuUXzvMKpa95GOntr89nN/mWKpk6abjgjmDcqFJ0lhDqkKnDfes+d8BBd5oEA8p41/Ykz7OfG7AiktVBpTQFW09MQh1NOvcLxVgiUUVRPwNRKrOeCekWDtOjZhASMETv3kI1ogvhHukOQ3ztDzrxvmwnLQ+cXl1EeD8gQnGDp3QLiJqxPgh2EdmANh4IzjRexoDn6BqhRGqLLIoLAbbkoiNrd6NYujrWW0N8KMMoVEXuJL2g4wIDAQAB", + PrivateKey = "2.Ytudv+Qk3ET9hN8whqpuGg==|ijsFhmjaf1aaT9uz+IPhVTzMS+2W/ldAP8LdT5VyJaFdx4HSdLcWSZvz5xWuuW94zfv1Qh+p3iQIuZOr29G4jcx47rYtz4ssiFtB7Ia552ZeF+cb7uuVg40CIe7ycuJQITk00o8gots+wFnaEvk0Vjgycnqutm0jpeBJ1joWJWqTVgSsYdUGLu7PiJywQ9NgY4+bJXqadlcviS3rhPKJXtiXYJhqJqSw+vI0Yxp96MJ0HcFJk/LG22YJPTvL5kzuDq/Wzj40kj8blQ+ag+xHD4P/KJ/MppEB3OpDw3UoJ50Ek+YB9pOqGxZtvqMEzBDsgh0yoz1O992UnhaUqtJ5e9Bxy3PA6cJsdyn9npduNOreEb8vePCidN2XC+chjJpPFpjms9muHLKgfaTIfpiJA2Tz8E9dvSyhHHTE1mY+xEA7P08BYKN3LNoSGIjdiZuouJ1V/KZvCssDfVG1tli2qpnhTIh4m3rAMhbM8WW3B7wCV8N0MpcJJSvndkVcMgRbgWcbivLeXuKdE/K98n01RvOLSJyslhLGCGEQQKw6N3HQ2iELfv84YQZi2fjDK+OqAmXDq1pNcjKX2I8dqBwl31tPC8qSZiWnfinwLdqQTvSQjOIyAHb4sSjAwgdMbCRzUTChRr09l+PAZqGWdMC5N2Bw+bA8WP0l2Wdxuv9Abxl3F7xGeAA9Rw9PU5wGKujaMRmO4V9MFjNyyCcw4D9pzKMW6OUKsHsHE7tsG7KskCzksHzrZGawAt0S41BYQA/JwePCrD3F6dM92anlC1LfA00KJb0tmFdU0yJNmJfR+S78yn8yM6wDgIs2cFB3W1fYfpfUvQm+zzPoEQihNxBxnwFsBtMAOtPy54FjSzKmxsQTrYT9E6NFb8k6ZIIm2gNeOPK9OUJgjw+4g2BXErM6ikHTzM3xcaTq/cQaePZ52emndw1qOtdV06hr2EeuLM8frfLHpsknUe8JeYeW5p9E8QdZjjSN9034usdYNamUdxzmn/Mw/ar8z1xSKS6zcaQoTQ7aYLEX3dWJndc4W64HyiaRkLjO6qLUFeOerfz5UvcxxRY89eAA0KLC2xnGkBMOhXxYzIB3lF8Zxqb4JMhoBGw1n31TDfhRDGDHHEAsZuAIcH7aC5RDVxU08Jxmw4oLmeTDZA5BFcqp2A3fusNVZUnfpmMy6DCJyFprlRl8jSlJMAvhbxVuuLFDZnjl77Z2of796Ur6DgmNwYtMPNEntZPIcZ76VPLWAL8lqiRBm20c4qiwr5rNSr5kry9bR1EfXHwFRjy5pxFQ+5+ilpRl8WPfT/iUuORd8J2wnCmghm7uxiJd9t82kX0s6benhL29dQ1etqt5soX2RnlfKan16GVWoI3xrljIQrCAY4xpdptSpglOnrpSClbN1nhGkDfFPNq2pWhQrDbznDknAJ9MxQaVnLYPhn7I849GMd7EvpSkydwQu7QXn9+H4jxn6UEntNGxcL0xkG+xippvZEe+HBvcDD40efDQW1bDbILLjPb4rNRx4d3xaQnVNaF7L33osm5LgfXAQSwHJiURdkU4zmhtPP4zn0br0OdFlR3mPcrkeNeSvs7FxiKtD6n6s+av+4bKjbLL1OyuwmTnMilL6p+m8ldte0yos/r+zOuxWeI=|euhiXWXehYbFQhlAV6LIECSIPCIRaHbNdr9OI4cTPUM=", + ApiKey = "CfGrD4MoJu3NprOBZNL8tu5ocmtnmU", + KdfIterations = 600000 + }); + + var organizationUser = await CreateUserAsync(factory, organizationId, user.Email, + OrganizationUserType.User, externalId: email); + + return (user, organizationUser); + } } diff --git a/test/Api.IntegrationTest/Platform/Controllers/PushControllerTests.cs b/test/Api.IntegrationTest/Platform/Controllers/PushControllerTests.cs index 4d86817a11..1f0ecd4835 100644 --- a/test/Api.IntegrationTest/Platform/Controllers/PushControllerTests.cs +++ b/test/Api.IntegrationTest/Platform/Controllers/PushControllerTests.cs @@ -8,8 +8,8 @@ using Bit.Core.Enums; using Bit.Core.Models; using Bit.Core.Models.Api; using Bit.Core.Models.Data; -using Bit.Core.NotificationHub; using Bit.Core.Platform.Installations; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Repositories; using NSubstitute; using Xunit; @@ -166,7 +166,7 @@ public class PushControllerTests yield return UserTyped(PushType.SyncOrgKeys); yield return UserTyped(PushType.SyncSettings); yield return UserTyped(PushType.LogOut); - yield return UserTyped(PushType.PendingSecurityTasks); + yield return UserTyped(PushType.RefreshSecurityTasks); yield return Typed(new PushSendRequestModel { diff --git a/test/Api.IntegrationTest/Vault/Controllers/SyncControllerTests.cs b/test/Api.IntegrationTest/Vault/Controllers/SyncControllerTests.cs new file mode 100644 index 0000000000..16d4b0fb66 --- /dev/null +++ b/test/Api.IntegrationTest/Vault/Controllers/SyncControllerTests.cs @@ -0,0 +1,100 @@ +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Vault.Models.Response; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.IntegrationTest.Vault.Controllers; + +public class SyncControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + + private readonly LoginHelper _loginHelper; + + private readonly IUserRepository _userRepository; + private string _ownerEmail = null!; + + public SyncControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + _userRepository = _factory.GetService(); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + // [BitAutoData] + public async Task Get_HaveNoMasterPassword_UserDecryptionMasterPasswordUnlockIsNull() + { + var tempEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(tempEmail); + await _loginHelper.LoginAsync(tempEmail); + + // Remove user's password. + var user = await _userRepository.GetByEmailAsync(tempEmail); + Assert.NotNull(user); + user.MasterPassword = null; + await _userRepository.UpsertAsync(user); + + var response = await _client.GetAsync("/sync"); + response.EnsureSuccessStatusCode(); + + var syncResponseModel = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(syncResponseModel); + Assert.NotNull(syncResponseModel.UserDecryption); + Assert.Null(syncResponseModel.UserDecryption.MasterPasswordUnlock); + } + + [Theory] + [BitAutoData(KdfType.PBKDF2_SHA256, 654_321, null, null)] + [BitAutoData(KdfType.Argon2id, 11, 128, 5)] + public async Task Get_HaveMasterPassword_UserDecryptionMasterPasswordUnlockNotNull( + KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + var tempEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(tempEmail); + await _loginHelper.LoginAsync(tempEmail); + + // Change KDF settings + var user = await _userRepository.GetByEmailAsync(tempEmail); + Assert.NotNull(user); + user.Kdf = kdfType; + user.KdfIterations = kdfIterations; + user.KdfMemory = kdfMemory; + user.KdfParallelism = kdfParallelism; + await _userRepository.UpsertAsync(user); + + var response = await _client.GetAsync("/sync"); + response.EnsureSuccessStatusCode(); + + var syncResponseModel = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(syncResponseModel); + Assert.NotNull(syncResponseModel.UserDecryption); + Assert.NotNull(syncResponseModel.UserDecryption.MasterPasswordUnlock); + Assert.NotNull(syncResponseModel.UserDecryption.MasterPasswordUnlock.Kdf); + Assert.Equal(kdfType, syncResponseModel.UserDecryption.MasterPasswordUnlock.Kdf.KdfType); + Assert.Equal(kdfIterations, syncResponseModel.UserDecryption.MasterPasswordUnlock.Kdf.Iterations); + Assert.Equal(kdfMemory, syncResponseModel.UserDecryption.MasterPasswordUnlock.Kdf.Memory); + Assert.Equal(kdfParallelism, syncResponseModel.UserDecryption.MasterPasswordUnlock.Kdf.Parallelism); + Assert.Equal(user.Key, syncResponseModel.UserDecryption.MasterPasswordUnlock.MasterKeyEncryptedUserKey); + Assert.Equal(user.Email.ToLower(), syncResponseModel.UserDecryption.MasterPasswordUnlock.Salt); + } +} diff --git a/test/Api.Test/AdminConsole/Authorization/OrganizationContextTests.cs b/test/Api.Test/AdminConsole/Authorization/OrganizationContextTests.cs new file mode 100644 index 0000000000..92109cea93 --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/OrganizationContextTests.cs @@ -0,0 +1,125 @@ +using System.Security.Claims; +using AutoFixture; +using Bit.Api.AdminConsole.Authorization; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Authorization; + +[SutProviderCustomize] +public class OrganizationContextTests +{ + [Theory, BitAutoData] + public async Task IsProviderUserForOrganization_UserIsProviderUser_ReturnsTrue( + Guid userId, Guid organizationId, Guid otherOrganizationId, + SutProvider sutProvider) + { + var claimsPrincipal = new ClaimsPrincipal(); + var providerUserOrganizations = new List + { + new() { OrganizationId = organizationId }, + new() { OrganizationId = otherOrganizationId } + }; + + sutProvider.GetDependency() + .GetProperUserId(claimsPrincipal) + .Returns(userId); + + sutProvider.GetDependency() + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed) + .Returns(providerUserOrganizations); + + var result = await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId); + + Assert.True(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed); + } + + public static IEnumerable UserIsNotProviderUserData() + { + // User has provider organizations, but not for the target organization + yield return + [ + new List + { + new Fixture().Create() + } + ]; + + // User has no provider organizations + yield return [Array.Empty()]; + } + + [Theory, BitMemberAutoData(nameof(UserIsNotProviderUserData))] + public async Task IsProviderUserForOrganization_UserIsNotProviderUser_ReturnsFalse( + IEnumerable providerUserOrganizations, + Guid userId, Guid organizationId, + SutProvider sutProvider) + { + var claimsPrincipal = new ClaimsPrincipal(); + + sutProvider.GetDependency() + .GetProperUserId(claimsPrincipal) + .Returns(userId); + + sutProvider.GetDependency() + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed) + .Returns(providerUserOrganizations); + + var result = await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId); + + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task IsProviderUserForOrganization_UserIdIsNull_ThrowsException( + Guid organizationId, + SutProvider sutProvider) + { + var claimsPrincipal = new ClaimsPrincipal(); + + sutProvider.GetDependency() + .GetProperUserId(claimsPrincipal) + .Returns((Guid?)null); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId)); + + Assert.Equal(OrganizationContext.NoUserIdError, exception.Message); + } + + [Theory, BitAutoData] + public async Task IsProviderUserForOrganization_UsesCaching( + Guid userId, Guid organizationId, + SutProvider sutProvider) + { + var claimsPrincipal = new ClaimsPrincipal(); + var providerUserOrganizations = new List + { + new() { OrganizationId = organizationId } + }; + + sutProvider.GetDependency() + .GetProperUserId(claimsPrincipal) + .Returns(userId); + + sutProvider.GetDependency() + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed) + .Returns(providerUserOrganizations); + + await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId); + await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId); + + await sutProvider.GetDependency() + .Received(1) + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed); + } +} diff --git a/test/Api.Test/AdminConsole/Authorization/Requirements/BasePermissionRequirementTests.cs b/test/Api.Test/AdminConsole/Authorization/Requirements/BasePermissionRequirementTests.cs new file mode 100644 index 0000000000..07d263b263 --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/Requirements/BasePermissionRequirementTests.cs @@ -0,0 +1,66 @@ +using Bit.Api.AdminConsole.Authorization.Requirements; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Core.Test.AdminConsole.Helpers; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Authorization.Requirements; + +public class BasePermissionRequirementTests +{ + [Theory, BitAutoData] + [CurrentContextOrganizationCustomize(Type = OrganizationUserType.Owner)] + public async Task Authorizes_Owners(CurrentContextOrganization organizationClaims) + { + var result = await new PermissionRequirement().AuthorizeAsync(organizationClaims, () => Task.FromResult(false)); + Assert.True(result); + } + + [Theory, BitAutoData] + [CurrentContextOrganizationCustomize(Type = OrganizationUserType.Admin)] + public async Task Authorizes_Admins(CurrentContextOrganization organizationClaims) + { + var result = await new PermissionRequirement().AuthorizeAsync(organizationClaims, () => Task.FromResult(false)); + Assert.True(result); + } + + [Theory, BitAutoData] + [CurrentContextOrganizationCustomize(Type = OrganizationUserType.User)] + public async Task Authorizes_Providers(CurrentContextOrganization organizationClaims) + { + var result = await new PermissionRequirement().AuthorizeAsync(organizationClaims, () => Task.FromResult(true)); + Assert.True(result); + } + + [Theory, BitAutoData] + [CurrentContextOrganizationCustomize(Type = OrganizationUserType.Custom)] + public async Task Authorizes_CustomPermission(CurrentContextOrganization organizationClaims) + { + organizationClaims.Permissions.ManageGroups = true; + var result = await new TestCustomPermissionRequirement().AuthorizeAsync(organizationClaims, () => Task.FromResult(false)); + Assert.True(result); + } + + [Theory, BitAutoData] + [CurrentContextOrganizationCustomize(Type = OrganizationUserType.User)] + public async Task DoesNotAuthorize_Users(CurrentContextOrganization organizationClaims) + { + var result = await new PermissionRequirement().AuthorizeAsync(organizationClaims, () => Task.FromResult(false)); + Assert.False(result); + } + + [Theory, BitAutoData] + [CurrentContextOrganizationCustomize(Type = OrganizationUserType.Custom)] + public async Task DoesNotAuthorize_OtherCustomPermissions(CurrentContextOrganization organizationClaims) + { + organizationClaims.Permissions.ManageGroups = true; + organizationClaims.Permissions = organizationClaims.Permissions.Invert(); + var result = await new TestCustomPermissionRequirement().AuthorizeAsync(organizationClaims, () => Task.FromResult(false)); + Assert.False(result); + } + + private class PermissionRequirement() : BasePermissionRequirement(_ => false); + private class TestCustomPermissionRequirement() : BasePermissionRequirement(p => p.ManageGroups); +} diff --git a/test/Api.Test/AdminConsole/Authorization/Requirements/ManageGroupsOrUsersRequirementTests.cs b/test/Api.Test/AdminConsole/Authorization/Requirements/ManageGroupsOrUsersRequirementTests.cs new file mode 100644 index 0000000000..1d6270ba1f --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/Requirements/ManageGroupsOrUsersRequirementTests.cs @@ -0,0 +1,84 @@ +using Bit.Api.AdminConsole.Authorization.Requirements; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Authorization.Requirements; + +[SutProviderCustomize] +public class ManageGroupsOrUsersRequirementTests +{ + [Theory] + [CurrentContextOrganizationCustomize] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.Owner)] + public async Task AuthorizeAsync_WhenUserTypeCanManageUsers_ThenRequestShouldBeAuthorized( + OrganizationUserType type, + CurrentContextOrganization organization, + SutProvider sutProvider) + { + organization.Type = type; + + var actual = await sutProvider.Sut.AuthorizeAsync(organization, () => Task.FromResult(false)); + + Assert.True(actual); + } + + [Theory] + [CurrentContextOrganizationCustomize] + [BitAutoData(OrganizationUserType.Custom, true, false)] + [BitAutoData(OrganizationUserType.Custom, false, true)] + public async Task AuthorizeAsync_WhenCustomUserThatCanManageUsersOrGroups_ThenRequestShouldBeAuthorized( + OrganizationUserType type, + bool canManageUsers, + bool canManageGroups, + CurrentContextOrganization organization, + SutProvider sutProvider) + { + organization.Type = type; + organization.Permissions = new Permissions { ManageUsers = canManageUsers, ManageGroups = canManageGroups }; + + var actual = await sutProvider.Sut.AuthorizeAsync(organization, () => Task.FromResult(false)); + + Assert.True(actual); + } + + [Theory] + [CurrentContextOrganizationCustomize] + [BitAutoData] + public async Task AuthorizeAsync_WhenProviderUserForAnOrganization_ThenRequestShouldBeAuthorized( + CurrentContextOrganization organization, + SutProvider sutProvider) + { + var actual = await sutProvider.Sut.AuthorizeAsync(organization, IsProviderUserForOrg); + + Assert.True(actual); + return; + + Task IsProviderUserForOrg() => Task.FromResult(true); + } + + [Theory] + [CurrentContextOrganizationCustomize] + [BitAutoData(OrganizationUserType.User)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task AuthorizeAsync_WhenUserCannotManageUsersOrGroupsAndIsNotAProviderUser_ThenRequestShouldBeDenied( + OrganizationUserType type, + CurrentContextOrganization organization, + SutProvider sutProvider) + { + organization.Type = type; + organization.Permissions = new Permissions { ManageUsers = false, ManageGroups = false }; // When Type is User, the canManage permissions don't matter + + var actual = await sutProvider.Sut.AuthorizeAsync(organization, IsNotProviderUserForOrg); + + Assert.False(actual); + return; + + Task IsNotProviderUserForOrg() => Task.FromResult(false); + } +} diff --git a/test/Api.Test/AdminConsole/Authorization/Requirements/PermissionRequirementsTests.cs b/test/Api.Test/AdminConsole/Authorization/Requirements/PermissionRequirementsTests.cs new file mode 100644 index 0000000000..1acfbd5be3 --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/Requirements/PermissionRequirementsTests.cs @@ -0,0 +1,88 @@ +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization.Requirements; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Core.Test.AdminConsole.Helpers; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Authorization.Requirements; + +public class PermissionRequirementsTests +{ + /// + /// Correlates each IOrganizationRequirement with its custom permission. If you add a new requirement, + /// add a new entry here to have it automatically included in the tests below. + /// + public static IEnumerable RequirementData => new List + { + new object[] { new AccessEventLogsRequirement(), nameof(Permissions.AccessEventLogs) }, + new object[] { new AccessImportExportRequirement(), nameof(Permissions.AccessImportExport) }, + new object[] { new AccessReportsRequirement(), nameof(Permissions.AccessReports) }, + new object[] { new ManageAccountRecoveryRequirement(), nameof(Permissions.ManageResetPassword) }, + new object[] { new ManageGroupsRequirement(), nameof(Permissions.ManageGroups) }, + new object[] { new ManagePoliciesRequirement(), nameof(Permissions.ManagePolicies) }, + new object[] { new ManageScimRequirement(), nameof(Permissions.ManageScim) }, + new object[] { new ManageSsoRequirement(), nameof(Permissions.ManageSso) }, + new object[] { new ManageUsersRequirement(), nameof(Permissions.ManageUsers) }, + }; + + [Theory] + [BitMemberAutoData(nameof(RequirementData))] + [CurrentContextOrganizationCustomize(Type = OrganizationUserType.User)] + public async Task Authorizes_Provider(IOrganizationRequirement requirement, string _, CurrentContextOrganization organization) + { + var result = await requirement.AuthorizeAsync(organization, () => Task.FromResult(true)); + Assert.True(result); + } + + [Theory] + [BitMemberAutoData(nameof(RequirementData))] + [CurrentContextOrganizationCustomize(Type = OrganizationUserType.Owner)] + public async Task Authorizes_Owner(IOrganizationRequirement requirement, string _, CurrentContextOrganization organization) + { + var result = await requirement.AuthorizeAsync(organization, () => Task.FromResult(false)); + Assert.True(result); + } + + [Theory] + [BitMemberAutoData(nameof(RequirementData))] + [CurrentContextOrganizationCustomize(Type = OrganizationUserType.Admin)] + public async Task Authorizes_Admin(IOrganizationRequirement requirement, string _, CurrentContextOrganization organization) + { + var result = await requirement.AuthorizeAsync(organization, () => Task.FromResult(false)); + Assert.True(result); + } + + [Theory] + [BitMemberAutoData(nameof(RequirementData))] + [CurrentContextOrganizationCustomize(Type = OrganizationUserType.Custom)] + public async Task Authorizes_Custom_With_Correct_Permission(IOrganizationRequirement requirement, string permissionName, CurrentContextOrganization organization) + { + organization.Permissions.SetPermission(permissionName, true); + var result = await requirement.AuthorizeAsync(organization, () => Task.FromResult(false)); + Assert.True(result); + } + + [Theory] + [BitMemberAutoData(nameof(RequirementData))] + [CurrentContextOrganizationCustomize(Type = OrganizationUserType.Custom)] + public async Task DoesNotAuthorize_Custom_With_Other_Permissions(IOrganizationRequirement requirement, string permissionName, CurrentContextOrganization organization) + { + organization.Permissions.SetPermission(permissionName, true); + organization.Permissions = organization.Permissions.Invert(); + var result = await requirement.AuthorizeAsync(organization, () => Task.FromResult(false)); + Assert.False(result); + } + + [Theory] + [BitMemberAutoData(nameof(RequirementData))] + [CurrentContextOrganizationCustomize(Type = OrganizationUserType.User)] + public async Task DoesNotAuthorize_User(IOrganizationRequirement requirement, string _, CurrentContextOrganization organization) + { + var result = await requirement.AuthorizeAsync(organization, () => Task.FromResult(false)); + Assert.False(result); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationConnectionsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationConnectionsControllerTests.cs index dff61aa2b4..078272d940 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationConnectionsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationConnectionsControllerTests.cs @@ -4,14 +4,14 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs index 352f089db7..f81221c605 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs @@ -28,7 +28,7 @@ public class OrganizationDomainControllerTests { sutProvider.GetDependency().ManageSso(orgId).Returns(false); - var requestAction = async () => await sutProvider.Sut.Get(orgId); + var requestAction = async () => await sutProvider.Sut.GetAll(orgId); await Assert.ThrowsAsync(requestAction); } @@ -40,7 +40,7 @@ public class OrganizationDomainControllerTests sutProvider.GetDependency().ManageSso(orgId).Returns(true); sutProvider.GetDependency().GetByIdAsync(orgId).ReturnsNull(); - var requestAction = async () => await sutProvider.Sut.Get(orgId); + var requestAction = async () => await sutProvider.Sut.GetAll(orgId); await Assert.ThrowsAsync(requestAction); } @@ -64,7 +64,7 @@ public class OrganizationDomainControllerTests } }); - var result = await sutProvider.Sut.Get(orgId); + var result = await sutProvider.Sut.GetAll(orgId); Assert.IsType>(result); Assert.Equal(orgId, result.Data.Select(x => x.OrganizationId).FirstOrDefault()); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs index fbb3ecbfe0..1dd0e86f39 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs @@ -25,6 +25,60 @@ public class OrganizationIntegrationControllerTests Type = IntegrationType.Webhook }; + [Theory, BitAutoData] + public async Task GetAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(false); + + await Assert.ThrowsAsync(() => sutProvider.Sut.GetAsync(organizationId)); + } + + [Theory, BitAutoData] + public async Task GetAsync_IntegrationsExist_ReturnsIntegrations( + SutProvider sutProvider, + Guid organizationId, + List integrations) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns(integrations); + + var result = await sutProvider.Sut.GetAsync(organizationId); + + await sutProvider.GetDependency().Received(1) + .GetManyByOrganizationAsync(organizationId); + + Assert.Equal(integrations.Count, result.Count); + Assert.All(result, r => Assert.IsType(r)); + } + + [Theory, BitAutoData] + public async Task GetAsync_NoIntegrations_ReturnsEmptyList( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([]); + + var result = await sutProvider.Sut.GetAsync(organizationId); + + Assert.Empty(result); + } + [Theory, BitAutoData] public async Task CreateAsync_Webhook_AllParamsProvided_Succeeds( SutProvider sutProvider, diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs index 4732ddd748..4ccfa70308 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs @@ -141,6 +141,131 @@ public class OrganizationIntegrationsConfigurationControllerTests await Assert.ThrowsAsync(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty)); } + [Theory, BitAutoData] + public async Task GetAsync_ConfigurationsExist_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + List organizationIntegrationConfigurations) + { + organizationIntegration.OrganizationId = organizationId; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .GetManyByIntegrationAsync(Arg.Any()) + .Returns(organizationIntegrationConfigurations); + + var result = await sutProvider.Sut.GetAsync(organizationId, organizationIntegration.Id); + Assert.NotNull(result); + Assert.Equal(organizationIntegrationConfigurations.Count, result.Count); + Assert.All(result, r => Assert.IsType(r)); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(organizationIntegration.Id); + await sutProvider.GetDependency().Received(1) + .GetManyByIntegrationAsync(organizationIntegration.Id); + } + + [Theory, BitAutoData] + public async Task GetAsync_NoConfigurationsExist_ReturnsEmptyList( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration) + { + organizationIntegration.OrganizationId = organizationId; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .GetManyByIntegrationAsync(Arg.Any()) + .Returns([]); + + var result = await sutProvider.Sut.GetAsync(organizationId, organizationIntegration.Id); + Assert.NotNull(result); + Assert.Empty(result); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(organizationIntegration.Id); + await sutProvider.GetDependency().Received(1) + .GetManyByIntegrationAsync(organizationIntegration.Id); + } + + // [Theory, BitAutoData] + // public async Task GetAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound( + // SutProvider sutProvider, + // Guid organizationId, + // OrganizationIntegration organizationIntegration) + // { + // organizationIntegration.OrganizationId = organizationId; + // sutProvider.Sut.Url = Substitute.For(); + // sutProvider.GetDependency() + // .OrganizationOwner(organizationId) + // .Returns(true); + // sutProvider.GetDependency() + // .GetByIdAsync(Arg.Any()) + // .Returns(organizationIntegration); + // sutProvider.GetDependency() + // .GetByIdAsync(Arg.Any()) + // .ReturnsNull(); + // + // await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(organizationId, Guid.Empty, Guid.Empty)); + // } + // + [Theory, BitAutoData] + public async Task GetAsync_IntegrationDoesNotExist_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .ReturnsNull(); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(organizationId, Guid.NewGuid())); + } + + [Theory, BitAutoData] + public async Task GetAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(organizationId, organizationIntegration.Id)); + } + + [Theory, BitAutoData] + public async Task GetAsync_UserIsNotOrganizationAdmin_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(false); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(organizationId, Guid.NewGuid())); + } + [Theory, BitAutoData] public async Task PostAsync_AllParamsProvided_Slack_Succeeds( SutProvider sutProvider, @@ -189,7 +314,7 @@ public class OrganizationIntegrationsConfigurationControllerTests { organizationIntegration.OrganizationId = organizationId; organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Url: "https://localhost", Scheme: "Bearer", Token: "AUTH-TOKEN"); + var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); model.Configuration = JsonSerializer.Serialize(webhookConfig); model.Template = "Template String"; model.Filters = null; @@ -227,7 +352,7 @@ public class OrganizationIntegrationsConfigurationControllerTests { organizationIntegration.OrganizationId = organizationId; organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Url: "https://localhost"); + var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost")); model.Configuration = JsonSerializer.Serialize(webhookConfig); model.Template = "Template String"; model.Filters = null; @@ -390,7 +515,7 @@ public class OrganizationIntegrationsConfigurationControllerTests { organizationIntegration.OrganizationId = organizationId; organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Url: "https://localhost", Scheme: "Bearer", Token: "AUTH-TOKEN"); + var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); model.Configuration = JsonSerializer.Serialize(webhookConfig); model.Template = null; @@ -477,7 +602,7 @@ public class OrganizationIntegrationsConfigurationControllerTests organizationIntegration.OrganizationId = organizationId; organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Url: "https://localhost", Scheme: "Bearer", Token: "AUTH-TOKEN"); + var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); model.Configuration = JsonSerializer.Serialize(webhookConfig); model.Template = "Template String"; model.Filters = null; @@ -520,7 +645,7 @@ public class OrganizationIntegrationsConfigurationControllerTests organizationIntegration.OrganizationId = organizationId; organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Url: "https://localhost"); + var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost")); model.Configuration = JsonSerializer.Serialize(webhookConfig); model.Template = "Template String"; model.Filters = null; @@ -561,7 +686,7 @@ public class OrganizationIntegrationsConfigurationControllerTests { organizationIntegration.OrganizationId = organizationId; organizationIntegration.Type = IntegrationType.Webhook; - var webhookConfig = new WebhookIntegrationConfiguration(Url: "https://localhost", Scheme: "Bearer", Token: "AUTH-TOKEN"); + var webhookConfig = new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"), Scheme: "Bearer", Token: "AUTH-TOKEN"); model.Configuration = JsonSerializer.Serialize(webhookConfig); model.Template = "Template String"; model.Filters = null; diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index de54a44bca..e5aa03f067 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -29,6 +29,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; using NSubstitute; using Xunit; @@ -257,7 +258,7 @@ public class OrganizationUsersControllerTests .GetUsersOrganizationClaimedStatusAsync(organizationUser.OrganizationId, Arg.Is>(ids => ids.Contains(organizationUser.Id))) .Returns(new Dictionary { { organizationUser.Id, true } }); - var response = await sutProvider.Sut.Get(organizationUser.Id, false); + var response = await sutProvider.Sut.Get(organizationUser.OrganizationId, organizationUser.Id, false); Assert.Equal(organizationUser.Id, response.Id); Assert.True(response.ManagedByOrganization); @@ -271,7 +272,7 @@ public class OrganizationUsersControllerTests SutProvider sutProvider) { GetMany_Setup(organizationAbility, organizationUsers, sutProvider); - var response = await sutProvider.Sut.Get(organizationAbility.Id, false, false); + var response = await sutProvider.Sut.GetAll(organizationAbility.Id, false, false); Assert.True(response.Data.All(r => organizationUsers.Any(ou => ou.Id == r.Id))); } @@ -305,84 +306,14 @@ public class OrganizationUsersControllerTests [Theory] [BitAutoData] - public async Task GetAccountRecoveryDetails_WithoutManageResetPasswordPermission_Throws( - Guid organizationId, - OrganizationUserBulkRequestModel bulkRequestModel, - SutProvider sutProvider) - { - sutProvider.GetDependency().ManageResetPassword(organizationId).Returns(false); - - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAccountRecoveryDetails(organizationId, bulkRequestModel)); - } - - [Theory] - [BitAutoData] - public async Task DeleteAccount_WhenUserCanManageUsers_Success( - Guid orgId, Guid id, User currentUser, SutProvider sutProvider) - { - sutProvider.GetDependency().ManageUsers(orgId).Returns(true); - sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser); - - await sutProvider.Sut.DeleteAccount(orgId, id); - - await sutProvider.GetDependency() - .Received(1) - .DeleteUserAsync(orgId, id, currentUser.Id); - } - - [Theory] - [BitAutoData] - public async Task DeleteAccount_WhenUserCannotManageUsers_ThrowsNotFoundException( + public async Task DeleteAccount_WhenCurrentUserNotFound_ReturnsUnauthorizedResult( Guid orgId, Guid id, SutProvider sutProvider) { - sutProvider.GetDependency().ManageUsers(orgId).Returns(false); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs((Guid?)null); - await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteAccount(orgId, id)); - } + var result = await sutProvider.Sut.DeleteAccount(orgId, id); - [Theory] - [BitAutoData] - public async Task DeleteAccount_WhenCurrentUserNotFound_ThrowsUnauthorizedAccessException( - Guid orgId, Guid id, SutProvider sutProvider) - { - sutProvider.GetDependency().ManageUsers(orgId).Returns(true); - sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs((User)null); - - await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteAccount(orgId, id)); - } - - [Theory] - [BitAutoData] - public async Task BulkDeleteAccount_WhenUserCanManageUsers_Success( - Guid orgId, OrganizationUserBulkRequestModel model, User currentUser, - List<(Guid, string)> deleteResults, SutProvider sutProvider) - { - sutProvider.GetDependency().ManageUsers(orgId).Returns(true); - sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser); - sutProvider.GetDependency() - .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id) - .Returns(deleteResults); - - var response = await sutProvider.Sut.BulkDeleteAccount(orgId, model); - - Assert.Equal(deleteResults.Count, response.Data.Count()); - Assert.True(response.Data.All(r => deleteResults.Any(res => res.Item1 == r.Id && res.Item2 == r.Error))); - await sutProvider.GetDependency() - .Received(1) - .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); - } - - [Theory] - [BitAutoData] - public async Task BulkDeleteAccount_WhenUserCannotManageUsers_ThrowsNotFoundException( - Guid orgId, OrganizationUserBulkRequestModel model, SutProvider sutProvider) - { - sutProvider.GetDependency().ManageUsers(orgId).Returns(false); - - await Assert.ThrowsAsync(() => - sutProvider.Sut.BulkDeleteAccount(orgId, model)); + Assert.IsType(result); } [Theory] diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 3484c9a995..00fd3c3b4e 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -2,10 +2,12 @@ using AutoFixture.Xunit2; using Bit.Api.AdminConsole.Controllers; using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.Models.Request.Organizations; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; @@ -29,6 +31,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tokens; +using Bit.Core.Utilities; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; using NSubstitute; using Xunit; @@ -293,4 +296,40 @@ public class OrganizationsControllerTests : IDisposable Assert.True(result.ResetPasswordEnabled); } + + [Theory, AutoData] + public async Task PutCollectionManagement_ValidRequest_Success( + Organization organization, + OrganizationCollectionManagementUpdateRequestModel model) + { + // Arrange + _currentContext.OrganizationOwner(organization.Id).Returns(true); + + var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually); + _pricingClient.GetPlan(Arg.Any()).Returns(plan); + + _organizationService + .UpdateCollectionManagementSettingsAsync( + organization.Id, + Arg.Is(s => + s.LimitCollectionCreation == model.LimitCollectionCreation && + s.LimitCollectionDeletion == model.LimitCollectionDeletion && + s.LimitItemDeletion == model.LimitItemDeletion && + s.AllowAdminAccessToAllCollectionItems == model.AllowAdminAccessToAllCollectionItems)) + .Returns(organization); + + // Act + await _sut.PutCollectionManagement(organization.Id, model); + + // Assert + await _organizationService + .Received(1) + .UpdateCollectionManagementSettingsAsync( + organization.Id, + Arg.Is(s => + s.LimitCollectionCreation == model.LimitCollectionCreation && + s.LimitCollectionDeletion == model.LimitCollectionDeletion && + s.LimitItemDeletion == model.LimitItemDeletion && + s.AllowAdminAccessToAllCollectionItems == model.AllowAdminAccessToAllCollectionItems)); + } } diff --git a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs index 9bbc8a77c0..376fb01493 100644 --- a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs @@ -1,12 +1,18 @@ -using Bit.Api.AdminConsole.Controllers; +#nullable enable + +using Bit.Api.AdminConsole.Controllers; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Context; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -16,98 +22,312 @@ namespace Bit.Api.Test.AdminConsole.Controllers; [SutProviderCustomize] public class SlackIntegrationControllerTests { + private const string _slackToken = "xoxb-test-token"; + private const string _validSlackCode = "A_test_code"; + [Theory, BitAutoData] - public async Task CreateAsync_AllParamsProvided_Succeeds(SutProvider sutProvider, Guid organizationId) + public async Task CreateAsync_AllParamsProvided_Succeeds( + SutProvider sutProvider, + OrganizationIntegration integration) { - var token = "xoxb-test-token"; + integration.Type = IntegrationType.Slack; + integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); sutProvider.GetDependency() - .ObtainTokenViaOAuth(Arg.Any(), Arg.Any()) - .Returns(token); + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(callInfo => callInfo.Arg()); - var requestAction = await sutProvider.Sut.CreateAsync(organizationId, "A_test_code"); + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + var requestAction = await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString()); await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Any()); + .UpsertAsync(Arg.Any()); Assert.IsType(requestAction); } [Theory, BitAutoData] - public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest(SutProvider sutProvider, Guid organizationId) + public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) { + integration.Type = IntegrationType.Slack; + integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, string.Empty)); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateAsync(string.Empty, state.ToString())); } [Theory, BitAutoData] - public async Task CreateAsync_SlackServiceReturnsEmpty_ThrowsBadRequest(SutProvider sutProvider, Guid organizationId) + public async Task CreateAsync_SlackServiceReturnsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) { + integration.Type = IntegrationType.Slack; + integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); sutProvider.GetDependency() - .ObtainTokenViaOAuth(Arg.Any(), Arg.Any()) + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) .Returns(string.Empty); + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, "A_test_code")); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); } [Theory, BitAutoData] - public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + public async Task CreateAsync_StateEmpty_ThrowsNotFound( + SutProvider sutProvider) { - var token = "xoxb-test-token"; sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(false); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); sutProvider.GetDependency() - .ObtainTokenViaOAuth(Arg.Any(), Arg.Any()) - .Returns(token); + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, "A_test_code")); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, String.Empty)); } [Theory, BitAutoData] - public async Task RedirectAsync_Success(SutProvider sutProvider, Guid organizationId) + public async Task CreateAsync_StateExpired_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) { - var expectedUrl = $"https://localhost/{organizationId}"; + var timeProvider = new FakeTimeProvider(new DateTime(2024, 4, 3, 2, 1, 0, DateTimeKind.Utc)); + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); + var state = IntegrationOAuthState.FromIntegration(integration, timeProvider); + timeProvider.Advance(TimeSpan.FromMinutes(30)); + + sutProvider.SetDependency(timeProvider); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasNonexistentIntegration_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasWrongOgranizationHash_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration, + OrganizationIntegration wrongOrgIntegration) + { + wrongOrgIntegration.Id = integration.Id; sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency().GetRedirectUrl(Arg.Any()).Returns(expectedUrl); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(wrongOrgIntegration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasNonEmptyIntegration_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Slack; + integration.Configuration = "{}"; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasNonSlackIntegration_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Hec; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_Success( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Configuration = null; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns(expectedUrl); + sutProvider.GetDependency() + .OrganizationOwner(integration.OrganizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(integration.OrganizationId) + .Returns([]); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(integration); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(expectedUrl); + + var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + var requestAction = await sutProvider.Sut.RedirectAsync(integration.OrganizationId); + + Assert.IsType(requestAction); + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Any()); + sutProvider.GetDependency().Received(1).GetRedirectUrl(Arg.Any(), expectedState.ToString()); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_IntegrationAlreadyExistsWithNullConfig_Success( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + integration.OrganizationId = organizationId; + integration.Configuration = null; + integration.Type = IntegrationType.Slack; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns(expectedUrl); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .HttpContext.Request.Scheme - .Returns("https"); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([integration]); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(expectedUrl); var requestAction = await sutProvider.Sut.RedirectAsync(organizationId); - var redirectResult = Assert.IsType(requestAction); - Assert.Equal(expectedUrl, redirectResult.Url); + var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + Assert.IsType(requestAction); + sutProvider.GetDependency().Received(1).GetRedirectUrl(Arg.Any(), expectedState.ToString()); } [Theory, BitAutoData] - public async Task RedirectAsync_SlackServiceReturnsEmpty_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + public async Task RedirectAsync_IntegrationAlreadyExistsWithConfig_ThrowsBadRequest( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) { + integration.OrganizationId = organizationId; + integration.Configuration = "{}"; + integration.Type = IntegrationType.Slack; + var expectedUrl = "https://localhost/"; + sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency().GetRedirectUrl(Arg.Any()).Returns(string.Empty); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns(expectedUrl); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([integration]); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(expectedUrl); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_SlackServiceReturnsEmpty_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + integration.OrganizationId = organizationId; + integration.Configuration = null; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns(expectedUrl); sutProvider.GetDependency() - .HttpContext.Request.Scheme - .Returns("https"); + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([]); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(integration); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(string.Empty); await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); } @@ -116,14 +336,9 @@ public class SlackIntegrationControllerTests public async Task RedirectAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency().GetRedirectUrl(Arg.Any()).Returns(string.Empty); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(false); - sutProvider.GetDependency() - .HttpContext.Request.Scheme - .Returns("https"); await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); } diff --git a/test/Api.Test/AdminConsole/Jobs/OrganizationSubscriptionUpdateJobTests.cs b/test/Api.Test/AdminConsole/Jobs/OrganizationSubscriptionUpdateJobTests.cs new file mode 100644 index 0000000000..e500fcae1d --- /dev/null +++ b/test/Api.Test/AdminConsole/Jobs/OrganizationSubscriptionUpdateJobTests.cs @@ -0,0 +1,60 @@ +using Bit.Api.AdminConsole.Jobs; +using Bit.Core; +using Bit.Core.AdminConsole.Models.Data.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Quartz; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Jobs; + +[SutProviderCustomize] +public class OrganizationSubscriptionUpdateJobTests +{ + [Theory] + [BitAutoData] + public async Task ExecuteJobAsync_WhenScimInviteUserIsDisabled_ThenQueryAndCommandAreNotExecuted( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) + .Returns(false); + + var contextMock = Substitute.For(); + + await sutProvider.Sut.Execute(contextMock); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationSubscriptionsToUpdateAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationSubscriptionAsync(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task ExecuteJobAsync_WhenScimInviteUserIsEnabled_ThenQueryAndCommandAreExecuted( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) + .Returns(true); + + var contextMock = Substitute.For(); + + await sutProvider.Sut.Execute(contextMock); + + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationSubscriptionsToUpdateAsync(); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationSubscriptionAsync(Arg.Any>()); + } +} diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs index 20831ec7d9..74fe75a9d7 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs @@ -17,13 +17,13 @@ public class OrganizationIntegrationConfigurationRequestModelTests Template = "template" }; - Assert.False(model.IsValidForType(IntegrationType.CloudBillingSync)); + Assert.False(condition: model.IsValidForType(IntegrationType.CloudBillingSync)); } [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] + [InlineData(data: null)] + [InlineData(data: "")] + [InlineData(data: " ")] public void IsValidForType_EmptyConfiguration_ReturnsFalse(string? config) { var model = new OrganizationIntegrationConfigurationRequestModel @@ -32,25 +32,81 @@ public class OrganizationIntegrationConfigurationRequestModelTests Template = "template" }; - var result = model.IsValidForType(IntegrationType.Slack); - - Assert.False(result); + Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); + Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); } [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] + [InlineData(data: "")] + [InlineData(data: " ")] + public void IsValidForType_EmptyNonNullHecConfiguration_ReturnsFalse(string? config) + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = config, + Template = "template" + }; + + Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); + } + + [Fact] + public void IsValidForType_NullHecConfiguration_ReturnsTrue() + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = null, + Template = "template" + }; + + Assert.True(condition: model.IsValidForType(IntegrationType.Hec)); + } + + [Theory] + [InlineData(data: "")] + [InlineData(data: " ")] + public void IsValidForType_EmptyNonNullDatadogConfiguration_ReturnsFalse(string? config) + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = config, + Template = "template" + }; + + Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); + } + + [Fact] + public void IsValidForType_NullDatadogConfiguration_ReturnsTrue() + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = null, + Template = "template" + }; + + Assert.True(condition: model.IsValidForType(IntegrationType.Datadog)); + } + + [Theory] + [InlineData(data: null)] + [InlineData(data: "")] + [InlineData(data: " ")] public void IsValidForType_EmptyTemplate_ReturnsFalse(string? template) { - var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration("https://example.com", "Bearer", "AUTH-TOKEN")); + var config = JsonSerializer.Serialize(value: new WebhookIntegrationConfiguration( + Uri: new Uri("https://localhost"), + Scheme: "Bearer", + Token: "AUTH-TOKEN")); var model = new OrganizationIntegrationConfigurationRequestModel { Configuration = config, Template = template }; - Assert.False(model.IsValidForType(IntegrationType.Webhook)); + Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); + Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); + Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); } [Fact] @@ -62,14 +118,16 @@ public class OrganizationIntegrationConfigurationRequestModelTests Template = "template" }; - Assert.False(model.IsValidForType(IntegrationType.Webhook)); + Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); + Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); + Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); } [Fact] public void IsValidForType_InvalidJsonFilters_ReturnsFalse() { - var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration("https://example.com")); + var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration(Uri: new Uri("https://example.com"))); var model = new OrganizationIntegrationConfigurationRequestModel { Configuration = config, @@ -89,13 +147,13 @@ public class OrganizationIntegrationConfigurationRequestModelTests Template = "template" }; - Assert.False(model.IsValidForType(IntegrationType.Scim)); + Assert.False(condition: model.IsValidForType(IntegrationType.Scim)); } [Fact] public void IsValidForType_ValidSlackConfiguration_ReturnsTrue() { - var config = JsonSerializer.Serialize(new SlackIntegrationConfiguration("C12345")); + var config = JsonSerializer.Serialize(value: new SlackIntegrationConfiguration(ChannelId: "C12345")); var model = new OrganizationIntegrationConfigurationRequestModel { @@ -103,7 +161,7 @@ public class OrganizationIntegrationConfigurationRequestModelTests Template = "template" }; - Assert.True(model.IsValidForType(IntegrationType.Slack)); + Assert.True(condition: model.IsValidForType(IntegrationType.Slack)); } [Fact] @@ -136,33 +194,39 @@ public class OrganizationIntegrationConfigurationRequestModelTests [Fact] public void IsValidForType_ValidNoAuthWebhookConfiguration_ReturnsTrue() { - var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration("https://example.com")); + var config = JsonSerializer.Serialize(value: new WebhookIntegrationConfiguration(Uri: new Uri("https://localhost"))); var model = new OrganizationIntegrationConfigurationRequestModel { Configuration = config, Template = "template" }; - Assert.True(model.IsValidForType(IntegrationType.Webhook)); + Assert.True(condition: model.IsValidForType(IntegrationType.Webhook)); } [Fact] public void IsValidForType_ValidWebhookConfiguration_ReturnsTrue() { - var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration("https://example.com", "Bearer", "AUTH-TOKEN")); + var config = JsonSerializer.Serialize(value: new WebhookIntegrationConfiguration( + Uri: new Uri("https://localhost"), + Scheme: "Bearer", + Token: "AUTH-TOKEN")); var model = new OrganizationIntegrationConfigurationRequestModel { Configuration = config, Template = "template" }; - Assert.True(model.IsValidForType(IntegrationType.Webhook)); + Assert.True(condition: model.IsValidForType(IntegrationType.Webhook)); } [Fact] public void IsValidForType_ValidWebhookConfigurationWithFilters_ReturnsTrue() { - var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration("https://example.com", "Bearer", "AUTH-TOKEN")); + var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration( + Uri: new Uri("https://example.com"), + Scheme: "Bearer", + Token: "AUTH-TOKEN")); var filters = JsonSerializer.Serialize(new IntegrationFilterGroup() { AndOperator = true, @@ -197,6 +261,6 @@ public class OrganizationIntegrationConfigurationRequestModelTests var unknownType = (IntegrationType)999; - Assert.False(model.IsValidForType(unknownType)); + Assert.False(condition: model.IsValidForType(unknownType)); } } diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs index fc9b399abd..81927a1bfe 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json; using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; using Xunit; @@ -70,7 +72,7 @@ public class OrganizationIntegrationRequestModelTests } [Fact] - public void Validate_Webhook_WithConfiguration_ReturnsConfigurationError() + public void Validate_Webhook_WithInvalidConfiguration_ReturnsConfigurationError() { var model = new OrganizationIntegrationRequestModel { @@ -82,7 +84,115 @@ public class OrganizationIntegrationRequestModelTests Assert.Single(results); Assert.Contains(nameof(model.Configuration), results[0].MemberNames); - Assert.Contains("must not include configuration", results[0].ErrorMessage); + Assert.Contains("Must include valid", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Webhook_WithValidConfiguration_ReturnsNoErrors() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Webhook, + Configuration = JsonSerializer.Serialize(new WebhookIntegration(new Uri("https://example.com"))) + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(results); + } + + [Fact] + public void Validate_Hec_WithNullConfiguration_ReturnsError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Hec, + Configuration = null + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Configuration), results[0].MemberNames); + Assert.Contains("Must include valid", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Hec_WithInvalidConfiguration_ReturnsError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Hec, + Configuration = "Not valid" + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Configuration), results[0].MemberNames); + Assert.Contains("Must include valid", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Hec_WithValidConfiguration_ReturnsNoErrors() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Hec, + Configuration = JsonSerializer.Serialize(new HecIntegration(Uri: new Uri("http://localhost"), Scheme: "Bearer", Token: "Token")) + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(results); + } + + [Fact] + public void Validate_Datadog_WithNullConfiguration_ReturnsError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Datadog, + Configuration = null + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Configuration), results[0].MemberNames); + Assert.Contains("Must include valid", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Datadog_WithInvalidConfiguration_ReturnsError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Datadog, + Configuration = "Not valid" + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Configuration), results[0].MemberNames); + Assert.Contains("Must include valid", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Datadog_WithValidConfiguration_ReturnsNoErrors() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Datadog, + Configuration = JsonSerializer.Serialize( + new DatadogIntegration(ApiKey: "API1234", Uri: new Uri("http://localhost")) + ) + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(results); } [Fact] diff --git a/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs b/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs new file mode 100644 index 0000000000..057680425a --- /dev/null +++ b/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs @@ -0,0 +1,303 @@ + +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Context; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Models.Request; + +[SutProviderCustomize] +public class SavePolicyRequestTests +{ + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_WithValidData_ReturnsCorrectSavePolicyModel( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var testData = new Dictionary { { "test", "value" } }; + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.TwoFactorAuthentication, + Enabled = true, + Data = testData + }, + Metadata = new Dictionary() + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.Equal(PolicyType.TwoFactorAuthentication, result.PolicyUpdate.Type); + Assert.Equal(organizationId, result.PolicyUpdate.OrganizationId); + Assert.True(result.PolicyUpdate.Enabled); + Assert.NotNull(result.PolicyUpdate.Data); + + var deserializedData = JsonSerializer.Deserialize>(result.PolicyUpdate.Data); + Assert.Equal("value", deserializedData["test"].ToString()); + + Assert.Equal(userId, result!.PerformedBy.UserId); + Assert.True(result!.PerformedBy.IsOrganizationOwnerOrProvider); + + Assert.IsType(result.Metadata); + } + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_WithNullData_HandlesCorrectly( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(false); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.SingleOrg, + Enabled = false, + Data = null + }, + Metadata = null + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.Null(result.PolicyUpdate.Data); + Assert.False(result.PolicyUpdate.Enabled); + + Assert.Equal(userId, result!.PerformedBy.UserId); + Assert.False(result!.PerformedBy.IsOrganizationOwnerOrProvider); + } + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_WithNonOrganizationOwner_HandlesCorrectly( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.SingleOrg, + Enabled = false, + Data = null + }, + Metadata = null + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.Null(result.PolicyUpdate.Data); + Assert.False(result.PolicyUpdate.Enabled); + + Assert.Equal(userId, result!.PerformedBy.UserId); + Assert.True(result!.PerformedBy.IsOrganizationOwnerOrProvider); + } + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_OrganizationDataOwnership_WithValidMetadata_ReturnsCorrectMetadata( + Guid organizationId, + Guid userId, + string defaultCollectionName) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }, + Metadata = new Dictionary + { + { "defaultUserCollectionName", defaultCollectionName } + } + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.IsType(result.Metadata); + var metadata = (OrganizationModelOwnershipPolicyModel)result.Metadata; + Assert.Equal(defaultCollectionName, metadata.DefaultUserCollectionName); + } + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_OrganizationDataOwnership_WithNullMetadata_ReturnsEmptyMetadata( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }, + Metadata = null + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.NotNull(result); + Assert.IsType(result.Metadata); + } + + private static readonly Dictionary _complexData = new Dictionary + { + { "stringValue", "test" }, + { "numberValue", 42 }, + { "boolValue", true }, + { "arrayValue", new[] { "item1", "item2" } }, + { "nestedObject", new Dictionary { { "nested", "value" } } } + }; + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_ComplexData_SerializesCorrectly( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.ResetPassword, + Enabled = true, + Data = _complexData + }, + Metadata = new Dictionary() + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + var deserializedData = JsonSerializer.Deserialize>(result.PolicyUpdate.Data); + Assert.Equal("test", deserializedData["stringValue"].GetString()); + Assert.Equal(42, deserializedData["numberValue"].GetInt32()); + Assert.True(deserializedData["boolValue"].GetBoolean()); + Assert.Equal(2, deserializedData["arrayValue"].GetArrayLength()); + var array = deserializedData["arrayValue"].EnumerateArray() + .Select(e => e.GetString()) + .ToArray(); + Assert.Contains("item1", array); + Assert.Contains("item2", array); + Assert.True(deserializedData["nestedObject"].TryGetProperty("nested", out var nestedValue)); + Assert.Equal("value", nestedValue.GetString()); + } + + + [Theory, BitAutoData] + public async Task MapToPolicyMetadata_UnknownPolicyType_ReturnsEmptyMetadata( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.MaximumVaultTimeout, + Enabled = true, + Data = null + }, + Metadata = new Dictionary + { + { "someProperty", "someValue" } + } + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.NotNull(result); + Assert.IsType(result.Metadata); + } + + [Theory, BitAutoData] + public async Task MapToPolicyMetadata_JsonSerializationException_ReturnsEmptyMetadata( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var errorDictionary = BuildErrorDictionary(); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }, + Metadata = errorDictionary + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.NotNull(result); + Assert.IsType(result.Metadata); + } + + private static Dictionary BuildErrorDictionary() + { + var circularDict = new Dictionary(); + circularDict["self"] = circularDict; + return circularDict; + } +} diff --git a/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs b/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs new file mode 100644 index 0000000000..babdf3894d --- /dev/null +++ b/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs @@ -0,0 +1,117 @@ +#nullable enable + +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Models.Response.Organizations; + +public class OrganizationIntegrationResponseModelTests +{ + [Theory, BitAutoData] + public void Status_CloudBillingSync_AlwaysNotApplicable(OrganizationIntegration oi) + { + oi.Type = IntegrationType.CloudBillingSync; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + Assert.Equal(OrganizationIntegrationStatus.NotApplicable, model.Status); + + model.Configuration = "{}"; + Assert.Equal(OrganizationIntegrationStatus.NotApplicable, model.Status); + } + + [Theory, BitAutoData] + public void Status_Scim_AlwaysNotApplicable(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Scim; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + Assert.Equal(OrganizationIntegrationStatus.NotApplicable, model.Status); + + model.Configuration = "{}"; + Assert.Equal(OrganizationIntegrationStatus.NotApplicable, model.Status); + } + + [Theory, BitAutoData] + public void Status_Slack_NullConfig_ReturnsInitiated(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Slack; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Initiated, model.Status); + } + + [Theory, BitAutoData] + public void Status_Slack_WithConfig_ReturnsCompleted(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Slack; + oi.Configuration = "{}"; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); + } + + [Theory, BitAutoData] + public void Status_Webhook_AlwaysCompleted(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Webhook; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); + + model.Configuration = "{}"; + Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); + } + + [Theory, BitAutoData] + public void Status_Hec_NullConfig_ReturnsInvalid(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Hec; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Invalid, model.Status); + } + + [Theory, BitAutoData] + public void Status_Hec_WithConfig_ReturnsCompleted(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Hec; + oi.Configuration = "{}"; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); + } + + [Theory, BitAutoData] + public void Status_Datadog_NullConfig_ReturnsInvalid(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Datadog; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Invalid, model.Status); + } + + [Theory, BitAutoData] + public void Status_Datadog_WithConfig_ReturnsCompleted(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Datadog; + oi.Configuration = "{}"; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); + } +} diff --git a/test/Api.Test/Api.Test.csproj b/test/Api.Test/Api.Test.csproj index d6b31ce930..fb75246d4f 100644 --- a/test/Api.Test/Api.Test.csproj +++ b/test/Api.Test/Api.Test.csproj @@ -25,9 +25,4 @@ - - - - -
diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 64261ede82..e81d51281d 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -4,11 +4,13 @@ using Bit.Api.Auth.Models.Request.Accounts; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Kdf; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Test.Common.AutoFixture.Attributes; @@ -31,6 +33,9 @@ public class AccountsControllerTests : IDisposable private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly IFeatureService _featureService; + private readonly ITwoFactorEmailService _twoFactorEmailService; + private readonly IChangeKdfCommand _changeKdfCommand; + public AccountsControllerTests() { @@ -43,6 +48,8 @@ public class AccountsControllerTests : IDisposable _twoFactorIsEnabledQuery = Substitute.For(); _tdeOffboardingPasswordCommand = Substitute.For(); _featureService = Substitute.For(); + _twoFactorEmailService = Substitute.For(); + _changeKdfCommand = Substitute.For(); _sut = new AccountsController( _organizationService, @@ -53,7 +60,9 @@ public class AccountsControllerTests : IDisposable _setInitialMasterPasswordCommand, _tdeOffboardingPasswordCommand, _twoFactorIsEnabledQuery, - _featureService + _featureService, + _twoFactorEmailService, + _changeKdfCommand ); } @@ -236,12 +245,18 @@ public class AccountsControllerTests : IDisposable { var user = GenerateExampleUser(); ConfigureUserServiceToReturnValidPrincipalFor(user); - _userService.ChangePasswordAsync(user, default, default, default, default) + _userService.ChangePasswordAsync(user, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Success)); - await _sut.PostPassword(new PasswordRequestModel()); + await _sut.PostPassword(new PasswordRequestModel + { + MasterPasswordHash = "masterPasswordHash", + NewMasterPasswordHash = "newMasterPasswordHash", + MasterPasswordHint = "masterPasswordHint", + Key = "key" + }); - await _userService.Received(1).ChangePasswordAsync(user, default, default, default, default); + await _userService.Received(1).ChangePasswordAsync(user, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -250,7 +265,13 @@ public class AccountsControllerTests : IDisposable ConfigureUserServiceToReturnNullPrincipal(); await Assert.ThrowsAsync( - () => _sut.PostPassword(new PasswordRequestModel()) + () => _sut.PostPassword(new PasswordRequestModel + { + MasterPasswordHash = "masterPasswordHash", + NewMasterPasswordHash = "newMasterPasswordHash", + MasterPasswordHint = "masterPasswordHint", + Key = "key" + }) ); } @@ -259,11 +280,17 @@ public class AccountsControllerTests : IDisposable { var user = GenerateExampleUser(); ConfigureUserServiceToReturnValidPrincipalFor(user); - _userService.ChangePasswordAsync(user, default, default, default, default) + _userService.ChangePasswordAsync(user, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Failed())); await Assert.ThrowsAsync( - () => _sut.PostPassword(new PasswordRequestModel()) + () => _sut.PostPassword(new PasswordRequestModel + { + MasterPasswordHash = "masterPasswordHash", + NewMasterPasswordHash = "newMasterPasswordHash", + MasterPasswordHint = "masterPasswordHint", + Key = "key" + }) ); } @@ -547,6 +574,70 @@ public class AccountsControllerTests : IDisposable Assert.Equal(model.VerifyDevices, user.VerifyDevices); } + [Theory] + [BitAutoData] + public async Task ResendNewDeviceVerificationEmail_WhenUserNotFound_ShouldFail( + UnauthenticatedSecretVerificationRequestModel model) + { + // Arrange + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult((User)null)); + + // Act & Assert + await Assert.ThrowsAsync(() => _sut.ResendNewDeviceOtpAsync(model)); + } + + [Theory, BitAutoData] + public async Task ResendNewDeviceVerificationEmail_WhenSecretNotValid_ShouldFail( + User user, + UnauthenticatedSecretVerificationRequestModel model) + { + // Arrange + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + _userService.VerifySecretAsync(user, Arg.Any()).Returns(Task.FromResult(false)); + + // Act & Assert + await Assert.ThrowsAsync(() => _sut.ResendNewDeviceOtpAsync(model)); + } + + [Theory, BitAutoData] + public async Task ResendNewDeviceVerificationEmail_WhenTokenValid_SendsEmail(User user, + UnauthenticatedSecretVerificationRequestModel model) + { + // Arrange + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + _userService.VerifySecretAsync(user, Arg.Any()).Returns(Task.FromResult(true)); + + // Act + await _sut.ResendNewDeviceOtpAsync(model); + + // Assert + await _twoFactorEmailService.Received(1).SendNewDeviceVerificationEmailAsync(user); + } + + [Theory] + [BitAutoData] + public async Task PostKdf_WithNullAuthenticationData_ShouldFail( + User user, PasswordRequestModel model) + { + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + model.AuthenticationData = null; + + // Act + await Assert.ThrowsAsync(() => _sut.PostKdf(model)); + } + + [Theory] + [BitAutoData] + public async Task PostKdf_WithNullUnlockData_ShouldFail( + User user, PasswordRequestModel model) + { + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + model.UnlockData = null; + + // Act + await Assert.ThrowsAsync(() => _sut.PostKdf(model)); + } + // Below are helper functions that currently belong to this // test class, but ultimately may need to be split out into // something greater in order to share common test steps with diff --git a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs new file mode 100644 index 0000000000..fc7eb0d93b --- /dev/null +++ b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs @@ -0,0 +1,370 @@ +using System.Security.Claims; +using Bit.Api.Auth.Controllers; +using Bit.Api.Auth.Models.Response; +using Bit.Api.Models.Response; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Request.AuthRequest; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Services; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Auth.Controllers; + +[ControllerCustomize(typeof(AuthRequestsController))] +[SutProviderCustomize] +public class AuthRequestsControllerTests +{ + const string _testGlobalSettingsBaseUri = "https://vault.test.dev"; + + [Theory, BitAutoData] + public async Task Get_ReturnsExpectedResult( + SutProvider sutProvider, + User user, + AuthRequest authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id) + .Returns([authRequest]); + + // Act + var result = await sutProvider.Sut.GetAll(); + + // Assert + Assert.NotNull(result); + var expectedCount = 1; + Assert.Equal(result.Data.Count(), expectedCount); + Assert.IsType>(result); + } + + [Theory, BitAutoData] + public async Task GetById_ThrowsNotFoundException( + SutProvider sutProvider, + User user, + AuthRequest authRequest) + { + // Arrange + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetAuthRequestAsync(authRequest.Id, user.Id) + .Returns((AuthRequest)null); + + // Act + // Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.Get(authRequest.Id)); + } + + [Theory, BitAutoData] + public async Task GetById_ReturnsAuthRequest( + SutProvider sutProvider, + User user, + AuthRequest authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetAuthRequestAsync(authRequest.Id, user.Id) + .Returns(authRequest); + + // Act + var result = await sutProvider.Sut.Get(authRequest.Id); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetPending_ReturnsExpectedResult( + SutProvider sutProvider, + User user, + PendingAuthRequestDetails authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetManyPendingAuthRequestByUserId(user.Id) + .Returns([authRequest]); + + // Act + var result = await sutProvider.Sut.GetPendingAuthRequestsAsync(); + + // Assert + Assert.NotNull(result); + var expectedCount = 1; + Assert.Equal(result.Data.Count(), expectedCount); + Assert.IsType>(result); + } + + [Theory, BitAutoData] + public async Task GetResponseById_ThrowsNotFoundException( + SutProvider sutProvider, + AuthRequest authRequest) + { + // Arrange + sutProvider.GetDependency() + .GetValidatedAuthRequestAsync(authRequest.Id, authRequest.AccessCode) + .Returns((AuthRequest)null); + + // Act + // Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.GetResponse(authRequest.Id, authRequest.AccessCode)); + } + + [Theory, BitAutoData] + public async Task GetResponseById_ReturnsAuthRequest( + SutProvider sutProvider, + AuthRequest authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + + sutProvider.GetDependency() + .GetValidatedAuthRequestAsync(authRequest.Id, authRequest.AccessCode) + .Returns(authRequest); + + // Act + var result = await sutProvider.Sut.GetResponse(authRequest.Id, authRequest.AccessCode); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Post_AdminApprovalRequest_ThrowsBadRequestException( + SutProvider sutProvider, + AuthRequestCreateRequestModel authRequest) + { + // Arrange + authRequest.Type = AuthRequestType.AdminApproval; + + // Act + // Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.Post(authRequest)); + + var expectedMessage = "You must be authenticated to create a request of that type."; + Assert.Equal(exception.Message, expectedMessage); + } + + [Theory, BitAutoData] + public async Task Post_ReturnsAuthRequest( + SutProvider sutProvider, + AuthRequestCreateRequestModel requestModel, + AuthRequest authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + + requestModel.Type = AuthRequestType.AuthenticateAndUnlock; + sutProvider.GetDependency() + .CreateAuthRequestAsync(requestModel) + .Returns(authRequest); + + // Act + var result = await sutProvider.Sut.Post(requestModel); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task PostAdminRequest_ReturnsAuthRequest( + SutProvider sutProvider, + AuthRequestCreateRequestModel requestModel, + AuthRequest authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + + requestModel.Type = AuthRequestType.AuthenticateAndUnlock; + sutProvider.GetDependency() + .CreateAuthRequestAsync(requestModel) + .Returns(authRequest); + + // Act + var result = await sutProvider.Sut.PostAdminRequest(requestModel); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Put_WithRequestNotApproved_ReturnsAuthRequest( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + AuthRequest authRequest) + { + // Arrange + SetBaseServiceUri(sutProvider); + requestModel.RequestApproved = false; // Not an approval, so validation should be skipped + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .UpdateAuthRequestAsync(authRequest.Id, user.Id, requestModel) + .Returns(authRequest); + + // Act + var result = await sutProvider.Sut + .Put(authRequest.Id, requestModel); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Put_WithApprovedRequest_ValidatesAndReturnsAuthRequest( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + AuthRequest currentAuthRequest, + AuthRequest updatedAuthRequest, + List pendingRequests) + { + // Arrange + SetBaseServiceUri(sutProvider); + requestModel.RequestApproved = true; // Approval triggers validation + currentAuthRequest.RequestDeviceIdentifier = "device-identifier-123"; + + // Setup pending requests - make the current request the most recent for its device + var mostRecentForDevice = new PendingAuthRequestDetails(currentAuthRequest, Guid.NewGuid()); + pendingRequests.Add(mostRecentForDevice); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + // Setup validation dependencies + sutProvider.GetDependency() + .GetAuthRequestAsync(currentAuthRequest.Id, user.Id) + .Returns(currentAuthRequest); + + sutProvider.GetDependency() + .GetManyPendingAuthRequestByUserId(user.Id) + .Returns(pendingRequests); + + sutProvider.GetDependency() + .UpdateAuthRequestAsync(currentAuthRequest.Id, user.Id, requestModel) + .Returns(updatedAuthRequest); + + // Act + var result = await sutProvider.Sut + .Put(currentAuthRequest.Id, requestModel); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Put_WithApprovedRequest_CurrentAuthRequestNotFound_ThrowsNotFoundException( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + Guid authRequestId) + { + // Arrange + requestModel.RequestApproved = true; // Approval triggers validation + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + // Current auth request not found + sutProvider.GetDependency() + .GetAuthRequestAsync(authRequestId, user.Id) + .Returns((AuthRequest)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.Put(authRequestId, requestModel)); + } + + [Theory, BitAutoData] + public async Task Put_WithApprovedRequest_NotMostRecentForDevice_ThrowsBadRequestException( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + AuthRequest currentAuthRequest, + List pendingRequests) + { + // Arrange + requestModel.RequestApproved = true; // Approval triggers validation + currentAuthRequest.RequestDeviceIdentifier = "device-identifier-123"; + + // Setup pending requests - make a different request the most recent for the same device + var differentAuthRequest = new AuthRequest + { + Id = Guid.NewGuid(), // Different ID than current request + RequestDeviceIdentifier = currentAuthRequest.RequestDeviceIdentifier, + UserId = user.Id, + Type = AuthRequestType.AuthenticateAndUnlock, + CreationDate = DateTime.UtcNow + }; + var mostRecentForDevice = new PendingAuthRequestDetails(differentAuthRequest, Guid.NewGuid()); + pendingRequests.Add(mostRecentForDevice); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetAuthRequestAsync(currentAuthRequest.Id, user.Id) + .Returns(currentAuthRequest); + + sutProvider.GetDependency() + .GetManyPendingAuthRequestByUserId(user.Id) + .Returns(pendingRequests); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.Put(currentAuthRequest.Id, requestModel)); + + Assert.Equal("This request is no longer valid. Make sure to approve the most recent request.", exception.Message); + } + + private void SetBaseServiceUri(SutProvider sutProvider) + { + sutProvider.GetDependency() + .BaseServiceUri + .Vault + .Returns(_testGlobalSettingsBaseUri); + } +} diff --git a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs index 540d23f98b..bed483f83a 100644 --- a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs @@ -73,7 +73,7 @@ public class DevicesControllerTest _deviceRepositoryMock.GetManyByUserIdWithDeviceAuth(userId).Returns(devicesWithPendingAuthData); // Act - var result = await _sut.Get(); + var result = await _sut.GetAll(); // Assert Assert.NotNull(result); @@ -94,6 +94,6 @@ public class DevicesControllerTest _userServiceMock.GetProperUserId(Arg.Any()).Returns((Guid?)null); // Act & Assert - await Assert.ThrowsAsync(() => _sut.Get()); + await Assert.ThrowsAsync(() => _sut.GetAll()); } } diff --git a/test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs new file mode 100644 index 0000000000..8348ba885d --- /dev/null +++ b/test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs @@ -0,0 +1,313 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request.Organizations; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Services; +using Bit.Core.Sso; +using Microsoft.Extensions.Localization; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Auth.Models.Request; + +public class OrganizationSsoRequestModelTests +{ + [Fact] + public void ToSsoConfig_WithOrganizationId_CreatesNewSsoConfig() + { + // Arrange + var organizationId = Guid.NewGuid(); + var model = new OrganizationSsoRequestModel + { + Enabled = true, + Identifier = "test-identifier", + Data = new SsoConfigurationDataRequest + { + ConfigType = SsoType.OpenIdConnect, + Authority = "https://example.com", + ClientId = "test-client", + ClientSecret = "test-secret" + } + }; + + // Act + var result = model.ToSsoConfig(organizationId); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.OrganizationId); + Assert.True(result.Enabled); + } + + [Fact] + public void ToSsoConfig_WithExistingConfig_UpdatesExistingConfig() + { + // Arrange + var organizationId = Guid.NewGuid(); + var existingConfig = new SsoConfig + { + Id = 1, + OrganizationId = organizationId, + Enabled = false + }; + + var model = new OrganizationSsoRequestModel + { + Enabled = true, + Identifier = "updated-identifier", + Data = new SsoConfigurationDataRequest + { + ConfigType = SsoType.Saml2, + IdpEntityId = "test-entity", + IdpSingleSignOnServiceUrl = "https://sso.example.com" + } + }; + + // Act + var result = model.ToSsoConfig(existingConfig); + + // Assert + Assert.Same(existingConfig, result); + Assert.Equal(organizationId, result.OrganizationId); + Assert.True(result.Enabled); + } +} + +public class SsoConfigurationDataRequestTests +{ + private readonly TestI18nService _i18nService; + private readonly ValidationContext _validationContext; + + public SsoConfigurationDataRequestTests() + { + _i18nService = new TestI18nService(); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(II18nService)).Returns(_i18nService); + _validationContext = new ValidationContext(new object(), serviceProvider, null); + } + + [Fact] + public void ToConfigurationData_MapsProperties() + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.OpenIdConnect, + MemberDecryptionType = MemberDecryptionType.KeyConnector, + Authority = "https://authority.example.com", + ClientId = "test-client-id", + ClientSecret = "test-client-secret", + IdpX509PublicCert = "-----BEGIN CERTIFICATE-----\nMIIC...test\n-----END CERTIFICATE-----", + SpOutboundSigningAlgorithm = null // Test default + }; + + // Act + var result = model.ToConfigurationData(); + + // Assert + Assert.Equal(SsoType.OpenIdConnect, result.ConfigType); + Assert.Equal(MemberDecryptionType.KeyConnector, result.MemberDecryptionType); + Assert.Equal("https://authority.example.com", result.Authority); + Assert.Equal("test-client-id", result.ClientId); + Assert.Equal("test-client-secret", result.ClientSecret); + Assert.Equal("MIIC...test", result.IdpX509PublicCert); // PEM headers stripped + Assert.Equal(SamlSigningAlgorithms.Sha256, result.SpOutboundSigningAlgorithm); // Default applied + Assert.Null(result.IdpArtifactResolutionServiceUrl); // Always null + } + + [Fact] + public void KeyConnectorEnabled_Setter_UpdatesMemberDecryptionType() + { + // Arrange + var model = new SsoConfigurationDataRequest(); + + // Act & Assert +#pragma warning disable CS0618 // Type or member is obsolete + model.KeyConnectorEnabled = true; + Assert.Equal(MemberDecryptionType.KeyConnector, model.MemberDecryptionType); + + model.KeyConnectorEnabled = false; + Assert.Equal(MemberDecryptionType.MasterPassword, model.MemberDecryptionType); +#pragma warning restore CS0618 // Type or member is obsolete + } + + // Validation Tests + [Fact] + public void Validate_OpenIdConnect_ValidData_NoErrors() + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.OpenIdConnect, + Authority = "https://example.com", + ClientId = "test-client", + ClientSecret = "test-secret" + }; + + // Act + var results = model.Validate(_validationContext).ToList(); + + // Assert + Assert.Empty(results); + } + + [Theory] + [InlineData("", "test-client", "test-secret", "AuthorityValidationError")] + [InlineData("https://example.com", "", "test-secret", "ClientIdValidationError")] + [InlineData("https://example.com", "test-client", "", "ClientSecretValidationError")] + public void Validate_OpenIdConnect_MissingRequiredFields_ReturnsErrors(string authority, string clientId, string clientSecret, string expectedError) + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.OpenIdConnect, + Authority = authority, + ClientId = clientId, + ClientSecret = clientSecret + }; + + // Act + var results = model.Validate(_validationContext).ToList(); + + // Assert + Assert.Single(results); + Assert.Equal(expectedError, results[0].ErrorMessage); + } + + [Fact] + public void Validate_Saml2_ValidData_NoErrors() + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.Saml2, + IdpEntityId = "https://idp.example.com", + IdpSingleSignOnServiceUrl = "https://sso.example.com", + IdpSingleLogoutServiceUrl = "https://logout.example.com" + }; + + // Act + var results = model.Validate(_validationContext).ToList(); + + // Assert + Assert.Empty(results); + } + + [Theory] + [InlineData("", "https://sso.example.com", "IdpEntityIdValidationError")] + [InlineData("not-a-valid-uri", "", "IdpSingleSignOnServiceUrlValidationError")] + public void Validate_Saml2_MissingRequiredFields_ReturnsErrors(string entityId, string signOnUrl, string expectedError) + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.Saml2, + IdpEntityId = entityId, + IdpSingleSignOnServiceUrl = signOnUrl + }; + + // Act + var results = model.Validate(_validationContext).ToList(); + + // Assert + Assert.Contains(results, r => r.ErrorMessage == expectedError); + } + + [Theory] + [InlineData("not-a-url")] + [InlineData("ftp://example.com")] + [InlineData("https://example.com