diff --git a/.checkmarx/config.yml b/.checkmarx/config.yml index 641da0eacb..e40c43b662 100644 --- a/.checkmarx/config.yml +++ b/.checkmarx/config.yml @@ -11,3 +11,7 @@ checkmarx: filter: "!test" kics: filter: "!dev,!.devcontainer" + sca: + filter: "!dev,!.devcontainer" + containers: + filter: "!dev,!.devcontainer" diff --git a/.devcontainer/community_dev/devcontainer.json b/.devcontainer/community_dev/devcontainer.json index ce3b8a21c6..c59ad3b839 100644 --- a/.devcontainer/community_dev/devcontainer.json +++ b/.devcontainer/community_dev/devcontainer.json @@ -3,10 +3,12 @@ "dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml", "service": "bitwarden_server", "workspaceFolder": "/workspace", + "initializeCommand": "mkdir -p dev/.data/keys dev/.data/mssql dev/.data/azurite dev/helpers/mssql", "features": { "ghcr.io/devcontainers/features/node:1": { - "version": "16" - } + "version": "22" + }, + "ghcr.io/devcontainers/features/rust:1": {} }, "mounts": [ { @@ -21,5 +23,27 @@ "extensions": ["ms-dotnettools.csdevkit"] } }, - "postCreateCommand": "bash .devcontainer/community_dev/postCreateCommand.sh" + "postCreateCommand": "bash .devcontainer/community_dev/postCreateCommand.sh", + "forwardPorts": [1080, 1433, 3306, 5432], + "portsAttributes": { + "default": { + "onAutoForward": "ignore" + }, + "1080": { + "label": "Mail Catcher", + "onAutoForward": "notify" + }, + "1433": { + "label": "SQL Server", + "onAutoForward": "notify" + }, + "3306": { + "label": "MySQL", + "onAutoForward": "notify" + }, + "5432": { + "label": "PostgreSQL", + "onAutoForward": "notify" + } + } } diff --git a/.devcontainer/community_dev/postCreateCommand.sh b/.devcontainer/community_dev/postCreateCommand.sh index 8f1813ed78..8ae3854168 100755 --- a/.devcontainer/community_dev/postCreateCommand.sh +++ b/.devcontainer/community_dev/postCreateCommand.sh @@ -3,11 +3,46 @@ export DEV_DIR=/workspace/dev export CONTAINER_CONFIG=/workspace/.devcontainer/community_dev git config --global --add safe.directory /workspace +if [[ -z "${CODESPACES}" ]]; then + allow_interactive=1 +else + echo "Doing non-interactive setup" + allow_interactive=0 +fi + +get_option() { + # Helper function for reading the value of an environment variable + # primarily but then falling back to an interactive question if allowed + # and lastly falling back to a default value input when either other + # option is available. + name_of_var="$1" + question_text="$2" + default_value="$3" + is_secret="$4" + + if [[ -n "${!name_of_var}" ]]; then + # If the env variable they gave us has a value, then use that value + echo "${!name_of_var}" + elif [[ "$allow_interactive" == 1 ]]; then + # If we can be interactive, then use the text they gave us to request input + if [[ "$is_secret" == 1 ]]; then + read -r -s -p "$question_text" response + echo "$response" + else + read -r -p "$question_text" response + echo "$response" + fi + else + # If no environment variable and not interactive, then just give back default value + echo "$default_value" + fi +} + get_installation_id_and_key() { pushd ./dev >/dev/null || exit echo "Please enter your installation id and key from https://bitwarden.com/host:" - read -r -p "Installation id: " INSTALLATION_ID - read -r -p "Installation key: " INSTALLATION_KEY + INSTALLATION_ID="$(get_option "INSTALLATION_ID" "Installation id: " "00000000-0000-0000-0000-000000000001")" + INSTALLATION_KEY="$(get_option "INSTALLATION_KEY" "Installation key: " "" 1)" jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" | .globalSettings.installation.key = \"$INSTALLATION_KEY\"" \ secrets.json.example >secrets.json # create/overwrite secrets.json @@ -30,11 +65,10 @@ configure_other_vars() { } one_time_setup() { - read -r -p \ - "Would you like to configure your secrets and certificates for the first time? + do_secrets_json_setup="$(get_option "SETUP_SECRETS_JSON" "Would you like to configure your secrets and certificates for the first time? WARNING: This will overwrite any existing secrets.json and certificate files. -Proceed? [y/N] " response - if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then +Proceed? [y/N] " "n")" + if [[ "$do_secrets_json_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then echo "Running one-time setup script..." sleep 1 get_installation_id_and_key @@ -50,11 +84,4 @@ Proceed? [y/N] " response fi } -# main -if [[ -z "${CODESPACES}" ]]; then - one_time_setup -else - # Ignore interactive elements when running in codespaces since they are not supported there - # TODO Write codespaces specific instructions and link here - echo "Running in codespaces, follow instructions here: https://contributing.bitwarden.com/getting-started/server/guide/ to continue the setup" -fi +one_time_setup diff --git a/.devcontainer/internal_dev/devcontainer.json b/.devcontainer/internal_dev/devcontainer.json index 862b9297c4..99e3057024 100644 --- a/.devcontainer/internal_dev/devcontainer.json +++ b/.devcontainer/internal_dev/devcontainer.json @@ -6,10 +6,12 @@ ], "service": "bitwarden_server", "workspaceFolder": "/workspace", + "initializeCommand": "mkdir -p dev/.data/keys dev/.data/mssql dev/.data/azurite dev/helpers/mssql", "features": { "ghcr.io/devcontainers/features/node:1": { - "version": "16" - } + "version": "22" + }, + "ghcr.io/devcontainers/features/rust:1": {} }, "mounts": [ { @@ -24,9 +26,18 @@ "extensions": ["ms-dotnettools.csdevkit"] } }, + "onCreateCommand": "bash .devcontainer/internal_dev/onCreateCommand.sh", "postCreateCommand": "bash .devcontainer/internal_dev/postCreateCommand.sh", - "forwardPorts": [1080, 1433, 3306, 5432, 10000, 10001, 10002], + "forwardPorts": [ + 1080, 1433, 3306, 5432, 10000, 10001, 10002, + 4000, 4001, 33656, 33657, 44519, 44559, + 46273, 46274, 50024, 51822, 51823, + 54103, 61840, 61841, 62911, 62912 + ], "portsAttributes": { + "default": { + "onAutoForward": "ignore" + }, "1080": { "label": "Mail Catcher", "onAutoForward": "notify" @@ -48,12 +59,76 @@ "onAutoForward": "notify" }, "10001": { - "label": "Azurite Storage Queue ", + "label": "Azurite Storage Queue", "onAutoForward": "notify" }, "10002": { "label": "Azurite Storage Table", "onAutoForward": "notify" + }, + "4000": { + "label": "Api (Cloud)", + "onAutoForward": "notify" + }, + "4001": { + "label": "Api (SelfHost)", + "onAutoForward": "notify" + }, + "33656": { + "label": "Identity (Cloud)", + "onAutoForward": "notify" + }, + "33657": { + "label": "Identity (SelfHost)", + "onAutoForward": "notify" + }, + "44519": { + "label": "Billing", + "onAutoForward": "notify" + }, + "44559": { + "label": "Scim", + "onAutoForward": "notify" + }, + "46273": { + "label": "Events (Cloud)", + "onAutoForward": "notify" + }, + "46274": { + "label": "Events (SelfHost)", + "onAutoForward": "notify" + }, + "50024": { + "label": "Icons", + "onAutoForward": "notify" + }, + "51822": { + "label": "Sso (Cloud)", + "onAutoForward": "notify" + }, + "51823": { + "label": "Sso (SelfHost)", + "onAutoForward": "notify" + }, + "54103": { + "label": "EventsProcessor", + "onAutoForward": "notify" + }, + "61840": { + "label": "Notifications (Cloud)", + "onAutoForward": "notify" + }, + "61841": { + "label": "Notifications (SelfHost)", + "onAutoForward": "notify" + }, + "62911": { + "label": "Admin (Cloud)", + "onAutoForward": "notify" + }, + "62912": { + "label": "Admin (SelfHost)", + "onAutoForward": "notify" } } } diff --git a/.devcontainer/internal_dev/onCreateCommand.sh b/.devcontainer/internal_dev/onCreateCommand.sh new file mode 100644 index 0000000000..71d466aae9 --- /dev/null +++ b/.devcontainer/internal_dev/onCreateCommand.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +export REPO_ROOT="$(git rev-parse --show-toplevel)" + +file="$REPO_ROOT/dev/custom-root-ca.crt" + +if [ -e "$file" ]; then + echo "Adding custom root CA" + sudo cp "$file" /usr/local/share/ca-certificates/ + sudo update-ca-certificates +else + echo "No custom root CA found, skipping..." +fi diff --git a/.devcontainer/internal_dev/postCreateCommand.sh b/.devcontainer/internal_dev/postCreateCommand.sh index 3fd278be26..ceef0ef0f5 100755 --- a/.devcontainer/internal_dev/postCreateCommand.sh +++ b/.devcontainer/internal_dev/postCreateCommand.sh @@ -108,7 +108,7 @@ Press to continue." fi run_mssql_migrations="$(get_option "RUN_MSSQL_MIGRATIONS" "Would you like us to run MSSQL Migrations for you? [y/N] " "n")" - if [[ "$do_azurite_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then + if [[ "$run_mssql_migrations" =~ ^([yY][eE][sS]|[yY])+$ ]]; then echo "Running migrations..." sleep 5 # wait for DB container to start dotnet run --project "$REPO_ROOT/util/MsSqlMigratorUtility" "$SQL_CONNECTION_STRING" diff --git a/.editorconfig b/.editorconfig index fd68808456..71dd40de98 100644 --- a/.editorconfig +++ b/.editorconfig @@ -71,10 +71,10 @@ dotnet_naming_symbols.any_async_methods.applicable_kinds = method dotnet_naming_symbols.any_async_methods.applicable_accessibilities = * dotnet_naming_symbols.any_async_methods.required_modifiers = async -dotnet_naming_style.end_in_async.required_prefix = +dotnet_naming_style.end_in_async.required_prefix = dotnet_naming_style.end_in_async.required_suffix = Async dotnet_naming_style.end_in_async.capitalization = pascal_case -dotnet_naming_style.end_in_async.word_separator = +dotnet_naming_style.end_in_async.word_separator = # Obsolete warnings, this should be removed or changed to warning once we address some of the obsolete items. dotnet_diagnostic.CS0618.severity = suggestion @@ -85,6 +85,12 @@ dotnet_diagnostic.CS0612.severity = suggestion # Remove unnecessary using directives https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005 dotnet_diagnostic.IDE0005.severity = warning +# Specify CultureInfo https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1304 +dotnet_diagnostic.CA1304.severity = warning + +# Specify IFormatProvider https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1305 +dotnet_diagnostic.CA1305.severity = warning + # CSharp code style settings: [*.cs] # Prefer "var" everywhere diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f0c85d98c1..c3e95e724b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -11,6 +11,9 @@ **/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre **/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre +# Scanning tools +.checkmarx/ @bitwarden/team-appsec + ## BRE team owns these workflows ## .github/workflows/publish.yml @bitwarden/dept-bre @@ -94,9 +97,7 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev .github/workflows/test-database.yml @bitwarden/team-platform-dev .github/workflows/test.yml @bitwarden/team-platform-dev **/*Platform* @bitwarden/team-platform-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 diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 77539ef839..0796c4dbdf 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -21,12 +21,6 @@ commitMessagePrefix: "[deps] AC:", reviewers: ["team:team-admin-console-dev"], }, - { - matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"], - description: "Admin & SSO npm packages", - commitMessagePrefix: "[deps] Auth:", - reviewers: ["team:team-auth-dev"], - }, { matchPackageNames: [ "DuoUniversal", @@ -182,6 +176,14 @@ matchUpdateTypes: ["minor"], addLabels: ["hold"], }, + { + groupName: "Admin and SSO npm dependencies", + matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"], + matchUpdateTypes: ["minor", "patch"], + description: "Admin & SSO npm packages", + commitMessagePrefix: "[deps] Auth:", + reviewers: ["team:team-auth-dev"], + }, { matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"], groupName: "EntityFrameworkCore", diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 694e9048a7..c207620bae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,7 @@ jobs: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Verify format run: dotnet format --verify-no-changes @@ -39,8 +39,7 @@ jobs: build-artifacts: name: Build Docker images runs-on: ubuntu-22.04 - needs: - - lint + needs: lint outputs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} permissions: @@ -120,10 +119,10 @@ jobs: fi - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Set up Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: "npm" cache-dependency-path: "**/package-lock.json" @@ -246,7 +245,7 @@ jobs: - name: Install Cosign if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1 + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 - name: Sign image with Cosign if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' @@ -264,14 +263,14 @@ jobs: - name: Scan Docker image id: container-scan - uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2 + uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3 with: image: ${{ steps.image-tags.outputs.primary_tag }} fail-build: false output-format: sarif - name: Upload Grype results to GitHub - uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4 + uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 with: sarif_file: ${{ steps.container-scan.outputs.sarif }} sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} @@ -295,7 +294,7 @@ jobs: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -401,8 +400,7 @@ jobs: build-mssqlmigratorutility: name: Build MSSQL migrator utility runs-on: ubuntu-22.04 - needs: - - lint + needs: lint defaults: run: shell: bash @@ -422,7 +420,7 @@ jobs: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Print environment run: | @@ -452,14 +450,13 @@ jobs: path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility if-no-files-found: error - self-host-build: - name: Trigger self-host build + bitwarden-lite-build: + name: Trigger Bitwarden lite build if: | github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') runs-on: ubuntu-22.04 - needs: - - build-artifacts + needs: build-artifacts permissions: id-token: write steps: @@ -505,11 +502,10 @@ jobs: }); trigger-k8s-deploy: - name: Trigger k8s deploy + name: Trigger K8s deploy if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 - needs: - - build-artifacts + needs: build-artifacts permissions: id-token: write steps: @@ -539,7 +535,7 @@ jobs: owner: ${{ github.repository_owner }} repositories: devops - - name: Trigger k8s deploy + - name: Trigger K8s deploy uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.app-token.outputs.token }} @@ -557,8 +553,7 @@ jobs: setup-ephemeral-environment: name: Setup Ephemeral Environment - needs: - - build-artifacts + needs: build-artifacts if: | needs.build-artifacts.outputs.has_secrets == 'true' && github.event_name == 'pull_request' @@ -581,7 +576,7 @@ jobs: - build-artifacts - upload - build-mssqlmigratorutility - - self-host-build + - bitwarden-lite-build - trigger-k8s-deploy permissions: id-token: write diff --git a/.github/workflows/cleanup-after-pr.yml b/.github/workflows/cleanup-after-pr.yml deleted file mode 100644 index 4e59f1fa96..0000000000 --- a/.github/workflows/cleanup-after-pr.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Container registry cleanup - -on: - pull_request: - types: [closed] - -env: - _AZ_REGISTRY: "bitwardenprod.azurecr.io" - -jobs: - build-docker: - name: Remove branch-specific Docker images - runs-on: ubuntu-22.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: Log in to Azure ACR - run: az acr login -n "$_AZ_REGISTRY" --only-show-errors - - ########## Remove Docker images ########## - - name: Remove the Docker image from ACR - env: - REF: ${{ github.event.pull_request.head.ref }} - SERVICES: | - services: - - Admin - - Api - - Attachments - - Events - - EventsProcessor - - Icons - - Identity - - K8S-Proxy - - MsSql - - Nginx - - Notifications - - Server - - Setup - - Sso - run: | - for SERVICE in $(echo "${SERVICES}" | yq e ".services[]" - ) - do - SERVICE_NAME=$(echo "$SERVICE" | awk '{print tolower($0)}') - IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name - - echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG" - TAG_EXISTS=$( - az acr repository show-tags --name "$_AZ_REGISTRY" --repository "$SERVICE_NAME" \ - | jq --arg TAG "$IMAGE_TAG" -e '. | any(. == $TAG)' - ) - - if [[ "$TAG_EXISTS" == "true" ]]; then - echo "[*] Tag exists. Removing tag" - az acr repository delete --name "$_AZ_REGISTRY" --image "$SERVICE_NAME:$IMAGE_TAG" --yes - else - echo "[*] Tag does not exist. No action needed" - fi - done - - - 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/test-database.yml b/.github/workflows/test-database.yml index 0fbdb5d069..25ff9d0488 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -49,7 +49,7 @@ jobs: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Restore tools run: dotnet tool restore @@ -156,7 +156,7 @@ jobs: run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"' - name: Report test results - uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0 + uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results @@ -183,7 +183,7 @@ jobs: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Print environment run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 550d943dbc..12b5355c33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,10 +32,10 @@ jobs: persist-credentials: false - name: Set up .NET - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0 - name: Install rust - uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable + uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable with: toolchain: stable @@ -59,7 +59,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@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0 + uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results diff --git a/.vscode/launch.json b/.vscode/launch.json index c407ba5604..74115dcc86 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -69,6 +69,28 @@ "preLaunchTask": "buildFullServer", "stopAll": true }, + { + "name": "Full Server with Seeder API", + "configurations": [ + "run-Admin", + "run-API", + "run-Events", + "run-EventsProcessor", + "run-Identity", + "run-Sso", + "run-Icons", + "run-Billing", + "run-Notifications", + "run-SeederAPI" + ], + "presentation": { + "hidden": false, + "group": "AA_compounds", + "order": 6 + }, + "preLaunchTask": "buildFullServerWithSeederApi", + "stopAll": true + }, { "name": "Self Host: Bit", "configurations": [ @@ -204,6 +226,17 @@ }, "preLaunchTask": "buildSso", }, + { + "name": "Seeder API", + "configurations": [ + "run-SeederAPI" + ], + "presentation": { + "hidden": false, + "group": "cloud", + }, + "preLaunchTask": "buildSeederAPI", + }, { "name": "Admin Self Host", "configurations": [ @@ -270,6 +303,17 @@ }, "preLaunchTask": "buildSso", }, + { + "name": "Seeder API Self Host", + "configurations": [ + "run-SeederAPI-SelfHost" + ], + "presentation": { + "hidden": false, + "group": "self-host", + }, + "preLaunchTask": "buildSeederAPI", + } ], "configurations": [ // Configurations represent run-only scenarios so that they can be used in multiple compounds @@ -311,6 +355,25 @@ "/Views": "${workspaceFolder}/Views" } }, + { + "name": "run-SeederAPI", + "presentation": { + "hidden": true, + }, + "requireExactSource": true, + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll", + "args": [], + "cwd": "${workspaceFolder}/util/SeederApi", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, { "name": "run-Billing", "presentation": { @@ -488,6 +551,27 @@ "/Views": "${workspaceFolder}/Views" } }, + { + "name": "run-SeederAPI-SelfHost", + "presentation": { + "hidden": true, + }, + "requireExactSource": true, + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll", + "args": [], + "cwd": "${workspaceFolder}/util/SeederApi", + "stopAtEntry": false, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:5048", + "developSelfHosted": "true", + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, { "name": "run-Admin-SelfHost", "presentation": { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 567f9b6e58..07a55fdeb3 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -43,6 +43,21 @@ "label": "buildFullServer", "hide": true, "dependsOrder": "sequence", + "dependsOn": [ + "buildAdmin", + "buildAPI", + "buildEventsProcessor", + "buildIdentity", + "buildSso", + "buildIcons", + "buildBilling", + "buildNotifications" + ], + }, + { + "label": "buildFullServerWithSeederApi", + "hide": true, + "dependsOrder": "sequence", "dependsOn": [ "buildAdmin", "buildAPI", @@ -52,6 +67,7 @@ "buildIcons", "buildBilling", "buildNotifications", + "buildSeederAPI" ], }, { @@ -89,6 +105,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -102,6 +121,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -115,6 +137,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -128,6 +153,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -141,6 +169,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -154,6 +185,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -167,6 +201,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile" }, { @@ -180,6 +217,29 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "buildSeederAPI", + "hide": true, + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/util/SeederApi/SeederApi.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -197,6 +257,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -214,6 +277,9 @@ "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], + "options": { + "cwd": "${workspaceFolder}" + }, "problemMatcher": "$msCompile", "group": { "kind": "build", @@ -224,6 +290,9 @@ "label": "test", "type": "shell", "command": "dotnet test", + "options": { + "cwd": "${workspaceFolder}" + }, "group": { "kind": "test", "isDefault": true diff --git a/Directory.Build.props b/Directory.Build.props index db3ccf40f5..c4c7f342fa 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.12.2 + 2026.1.1 Bit.$(MSBuildProjectName) enable @@ -13,21 +13,21 @@ true - + - + 18.0.1 - + 2.6.6 - + 2.5.6 - + 6.0.0 - + 5.1.0 - + 4.18.1 - + 4.18.1 \ No newline at end of file diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 6786ad610c..409906e2d0 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29102.190 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36705.20 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src - AGPL", "src - AGPL", "{DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D}" EndProject @@ -11,19 +11,19 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{DD5BD056-4 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{458155D3-BCBC-481D-B37A-40D2ED10F0A4}" ProjectSection(SolutionItems) = preProject + .dockerignore = .dockerignore + .editorconfig = .editorconfig + .gitignore = .gitignore + CONTRIBUTING.md = CONTRIBUTING.md Directory.Build.props = Directory.Build.props global.json = global.json - .gitignore = .gitignore - README.md = README.md - .editorconfig = .editorconfig - TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md - SECURITY.md = SECURITY.md - LICENSE_FAQ.md = LICENSE_FAQ.md - LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt - LICENSE_AGPL.txt = LICENSE_AGPL.txt LICENSE.txt = LICENSE.txt - CONTRIBUTING.md = CONTRIBUTING.md - .dockerignore = .dockerignore + LICENSE_AGPL.txt = LICENSE_AGPL.txt + LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt + LICENSE_FAQ.md = LICENSE_FAQ.md + README.md = README.md + SECURITY.md = SECURITY.md + TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "src\Core\Core.csproj", "{3973D21B-A692-4B60-9B70-3631C057423A}" @@ -134,10 +134,19 @@ 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}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.IntegrationTest", "test\Server.IntegrationTest\Server.IntegrationTest.csproj", "{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -350,10 +359,26 @@ Global {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 + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.Build.0 = Release|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.Build.0 = Release|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.Build.0 = Release|Any CPU + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -410,7 +435,11 @@ Global {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} + {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} + {A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} + {FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} + {E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/bitwarden_license/src/Scim/Startup.cs b/bitwarden_license/src/Scim/Startup.cs index 2a84faa8dd..a912562f72 100644 --- a/bitwarden_license/src/Scim/Startup.cs +++ b/bitwarden_license/src/Scim/Startup.cs @@ -44,6 +44,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/bitwarden_license/src/Scim/appsettings.Production.json b/bitwarden_license/src/Scim/appsettings.Production.json index d9efbcda12..a6578c08dc 100644 --- a/bitwarden_license/src/Scim/appsettings.Production.json +++ b/bitwarden_license/src/Scim/appsettings.Production.json @@ -23,11 +23,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index afbef321a9..3d998b6a75 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -2,7 +2,7 @@ using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; @@ -45,7 +45,7 @@ public class AccountController : Controller private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoUserRepository _ssoUserRepository; private readonly IUserRepository _userRepository; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IUserService _userService; private readonly II18nService _i18nService; private readonly UserManager _userManager; @@ -67,7 +67,7 @@ public class AccountController : Controller ISsoConfigRepository ssoConfigRepository, ISsoUserRepository ssoUserRepository, IUserRepository userRepository, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IUserService userService, II18nService i18nService, UserManager userManager, @@ -88,7 +88,7 @@ public class AccountController : Controller _userRepository = userRepository; _ssoConfigRepository = ssoConfigRepository; _ssoUserRepository = ssoUserRepository; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _userService = userService; _i18nService = i18nService; _userManager = userManager; @@ -462,6 +462,7 @@ public class AccountController : Controller // FIXME: Update this file to be null safe and then delete the line below #nullable disable var provider = result.Properties.Items["scheme"]; + //Todo: Validate provider is a valid GUID with TryParse instead. When this is invalid it throws an exception var orgId = new Guid(provider); var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); if (ssoConfig == null || !ssoConfig.Enabled) @@ -615,7 +616,7 @@ public class AccountController : Controller // 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 + // We've verified that the user is Accepted or Confirmed, so we can create an SsoUser link and proceed // with authentication. await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser); @@ -686,9 +687,8 @@ public class AccountController : Controller await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization); // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email - var twoFactorPolicy = - await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication); - if (twoFactorPolicy != null && twoFactorPolicy.Enabled) + var twoFactorPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.TwoFactorAuthentication); + if (twoFactorPolicy.Enabled) { newUser.SetTwoFactorProviders(new Dictionary { diff --git a/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs b/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs new file mode 100644 index 0000000000..ecb2f36cec --- /dev/null +++ b/bitwarden_license/src/Sso/IdentityServer/DistributedCachePersistedGrantStore.cs @@ -0,0 +1,102 @@ +using Bit.Sso.Utilities; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Sso.IdentityServer; + +/// +/// Distributed cache-backed persisted grant store for short-lived grants. +/// Uses IFusionCache (which wraps IDistributedCache) for horizontal scaling support, +/// and fall back to in-memory caching if Redis is not configured. +/// Designed for SSO authorization codes which are short-lived (5 minutes) and single-use. +/// +/// +/// This is purposefully a different implementation from how Identity solves Persisted Grants. +/// Because even flavored grant store, e.g., AuthorizationCodeGrantStore, can add intermediary +/// logic to a grant's handling by type, the fact that they all wrap IdentityServer's IPersistedGrantStore +/// leans on IdentityServer's opinion that all grants, regardless of type, go to the same persistence +/// mechanism (cache, database). +/// +/// +public class DistributedCachePersistedGrantStore : IPersistedGrantStore +{ + private readonly IFusionCache _cache; + + public DistributedCachePersistedGrantStore( + [FromKeyedServices(PersistedGrantsDistributedCacheConstants.CacheKey)] IFusionCache cache) + { + _cache = cache; + } + + public async Task GetAsync(string key) + { + var result = await _cache.TryGetAsync(key); + + if (!result.HasValue) + { + return null; + } + + var grant = result.Value; + + // Check if grant has expired - remove expired grants from cache + if (grant.Expiration.HasValue && grant.Expiration.Value < DateTime.UtcNow) + { + await RemoveAsync(key); + return null; + } + + return grant; + } + + public Task> GetAllAsync(PersistedGrantFilter filter) + { + // Cache stores are key-value based and don't support querying by filter criteria. + // This method is typically used for cleanup operations on long-lived grants in databases. + // For SSO's short-lived authorization codes, we rely on TTL expiration instead. + + return Task.FromResult(Enumerable.Empty()); + } + + public Task RemoveAllAsync(PersistedGrantFilter filter) + { + // Revocation Strategy: SSO's logout flow (AccountController.LogoutAsync) only clears local + // authentication cookies and performs federated logout with external IdPs. It does not invoke + // Duende's EndSession or TokenRevocation endpoints. Authorization codes are single-use and expire + // within 5 minutes, making explicit revocation unnecessary for SSO's security model. + // https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/ + + // Cache stores are key-value based and don't support bulk deletion by filter. + // This method is typically used for cleanup operations on long-lived grants in databases. + // For SSO's short-lived authorization codes, we rely on TTL expiration instead. + + return Task.FromResult(0); + } + + public async Task RemoveAsync(string key) + { + await _cache.RemoveAsync(key); + } + + public async Task StoreAsync(PersistedGrant grant) + { + // Calculate TTL based on grant expiration + var duration = grant.Expiration.HasValue + ? grant.Expiration.Value - DateTime.UtcNow + : TimeSpan.FromMinutes(5); // Default to 5 minutes if no expiration set + + // Ensure positive duration + if (duration <= TimeSpan.Zero) + { + return; + } + + // Cache key "sso-grants:" is configured by service registration. Going through the consumed KeyedService will + // give us a consistent cache key prefix for these grants. + await _cache.SetAsync( + grant.Key, + grant, + new FusionCacheEntryOptions { Duration = duration }); + } +} diff --git a/bitwarden_license/src/Sso/Startup.cs b/bitwarden_license/src/Sso/Startup.cs index 2f83f3dad0..c4c676d51f 100644 --- a/bitwarden_license/src/Sso/Startup.cs +++ b/bitwarden_license/src/Sso/Startup.cs @@ -8,7 +8,6 @@ using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Bit.Sso.Utilities; using Duende.IdentityServer.Services; -using Microsoft.IdentityModel.Logging; using Stripe; namespace Bit.Sso; @@ -41,6 +40,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); @@ -90,20 +90,15 @@ public class Startup public void Configure( IApplicationBuilder app, - IWebHostEnvironment env, + IWebHostEnvironment environment, IHostApplicationLifetime appLifetime, GlobalSettings globalSettings, ILogger logger) { - if (env.IsDevelopment() || globalSettings.SelfHosted) - { - IdentityModelEventSource.ShowPII = true; - } - // Add general security headers app.UseMiddleware(); - if (!env.IsDevelopment()) + if (!environment.IsDevelopment()) { var uri = new Uri(globalSettings.BaseServiceUri.Sso); app.Use(async (ctx, next) => @@ -119,7 +114,7 @@ public class Startup app.UseForwardedHeaders(globalSettings); } - if (env.IsDevelopment()) + if (environment.IsDevelopment()) { app.UseDeveloperExceptionPage(); app.UseCookiePolicy(); diff --git a/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs b/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs new file mode 100644 index 0000000000..3ec45377e3 --- /dev/null +++ b/bitwarden_license/src/Sso/Utilities/PersistedGrantsDistributedCacheConstants.cs @@ -0,0 +1,10 @@ +namespace Bit.Sso.Utilities; + +public static class PersistedGrantsDistributedCacheConstants +{ + /// + /// The SSO Persisted Grant cache key. Identifies the keyed service consumed by the SSO Persisted Grant Store as + /// well as the cache key/namespace for grant storage. + /// + public const string CacheKey = "sso-grants"; +} diff --git a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs index a51a04f5c8..da7a79535e 100644 --- a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Bit.Sso.IdentityServer; using Bit.Sso.Models; using Duende.IdentityServer.Models; using Duende.IdentityServer.ResponseHandling; +using Duende.IdentityServer.Stores; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Sustainsys.Saml2.AspNetCore2; @@ -77,6 +78,17 @@ public static class ServiceCollectionExtensions }) .AddIdentityServerCertificate(env, globalSettings); + // PM-23572 + // Register named FusionCache for SSO authorization code grants. + // Provides separation of concerns and automatic Redis/in-memory negotiation + // .AddInMemoryCaching should still persist above; this handles configuration caching, etc., + // and is separate from this keyed service, which only serves grant negotiation. + services.AddExtendedCache(PersistedGrantsDistributedCacheConstants.CacheKey, globalSettings); + + // Store authorization codes in distributed cache for horizontal scaling + // Uses named FusionCache which gracefully degrades to in-memory when Redis isn't configured + services.AddSingleton(); + return identityServerBuilder; } } diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index f5e0468f87..efeee7f4ca 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.93.2", + "sass": "1.97.2", "sass-loader": "16.0.5", - "webpack": "5.102.1", + "webpack": "5.104.1", "webpack-cli": "5.1.4" } }, @@ -749,9 +749,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", - "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", + "version": "2.9.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz", + "integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -792,9 +792,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -813,11 +813,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -834,9 +834,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001751", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", - "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "version": "1.0.30001763", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", + "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", "dev": true, "funding": [ { @@ -988,9 +988,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.237", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", - "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true, "license": "ISC" }, @@ -1022,9 +1022,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -1418,13 +1418,17 @@ } }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/locate-path": { @@ -1541,9 +1545,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.26", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", - "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -1874,9 +1878,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", - "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "version": "1.97.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz", + "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", "peer": true, @@ -2109,9 +2113,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2165,9 +2169,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -2217,9 +2221,9 @@ } }, "node_modules/webpack": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", - "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", "peer": true, @@ -2232,21 +2236,21 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", + "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index df46444aca..b0a1849421 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.93.2", + "sass": "1.97.2", "sass-loader": "16.0.5", - "webpack": "5.102.1", + "webpack": "5.104.1", "webpack-cli": "5.1.4" } } diff --git a/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs b/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs new file mode 100644 index 0000000000..c0aa93f068 --- /dev/null +++ b/bitwarden_license/test/SSO.Test/IdentityServer/DistributedCachePersistedGrantStoreTests.cs @@ -0,0 +1,257 @@ +using Bit.Sso.IdentityServer; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; +using NSubstitute; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.SSO.Test.IdentityServer; + +public class DistributedCachePersistedGrantStoreTests +{ + private readonly IFusionCache _cache; + private readonly DistributedCachePersistedGrantStore _sut; + + public DistributedCachePersistedGrantStoreTests() + { + _cache = Substitute.For(); + _sut = new DistributedCachePersistedGrantStore(_cache); + } + + [Fact] + public async Task StoreAsync_StoresGrantWithCalculatedTTL() + { + // Arrange + var grant = CreateTestGrant("test-key", expiration: DateTime.UtcNow.AddMinutes(5)); + + // Act + await _sut.StoreAsync(grant); + + // Assert + await _cache.Received(1).SetAsync( + "test-key", + grant, + Arg.Is(opts => + opts.Duration >= TimeSpan.FromMinutes(4.9) && + opts.Duration <= TimeSpan.FromMinutes(5))); + } + + [Fact] + public async Task StoreAsync_WithNoExpiration_UsesDefaultFiveMinuteTTL() + { + // Arrange + var grant = CreateTestGrant("no-expiry-key", expiration: null); + + // Act + await _sut.StoreAsync(grant); + + // Assert + await _cache.Received(1).SetAsync( + "no-expiry-key", + grant, + Arg.Is(opts => opts.Duration == TimeSpan.FromMinutes(5))); + } + + [Fact] + public async Task StoreAsync_WithAlreadyExpiredGrant_DoesNotStore() + { + // Arrange + var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1)); + + // Act + await _sut.StoreAsync(expiredGrant); + + // Assert + await _cache.DidNotReceive().SetAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task StoreAsync_EnablesDistributedCache() + { + // Arrange + var grant = CreateTestGrant("distributed-key", expiration: DateTime.UtcNow.AddMinutes(5)); + + // Act + await _sut.StoreAsync(grant); + + // Assert + await _cache.Received(1).SetAsync( + "distributed-key", + grant, + Arg.Is(opts => + opts.SkipDistributedCache == false && + opts.SkipDistributedCacheReadWhenStale == false)); + } + + [Fact] + public async Task GetAsync_WithValidGrant_ReturnsGrant() + { + // Arrange + var grant = CreateTestGrant("valid-key", expiration: DateTime.UtcNow.AddMinutes(5)); + _cache.TryGetAsync("valid-key") + .Returns(MaybeValue.FromValue(grant)); + + // Act + var result = await _sut.GetAsync("valid-key"); + + // Assert + Assert.NotNull(result); + Assert.Equal("valid-key", result.Key); + Assert.Equal("authorization_code", result.Type); + Assert.Equal("test-subject", result.SubjectId); + await _cache.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task GetAsync_WithNonExistentKey_ReturnsNull() + { + // Arrange + _cache.TryGetAsync("nonexistent-key") + .Returns(MaybeValue.None); + + // Act + var result = await _sut.GetAsync("nonexistent-key"); + + // Assert + Assert.Null(result); + await _cache.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task GetAsync_WithExpiredGrant_RemovesAndReturnsNull() + { + // Arrange + var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1)); + _cache.TryGetAsync("expired-key") + .Returns(MaybeValue.FromValue(expiredGrant)); + + // Act + var result = await _sut.GetAsync("expired-key"); + + // Assert + Assert.Null(result); + await _cache.Received(1).RemoveAsync("expired-key"); + } + + [Fact] + public async Task GetAsync_WithNoExpiration_ReturnsGrant() + { + // Arrange + var grant = CreateTestGrant("no-expiry-key", expiration: null); + _cache.TryGetAsync("no-expiry-key") + .Returns(MaybeValue.FromValue(grant)); + + // Act + var result = await _sut.GetAsync("no-expiry-key"); + + // Assert + Assert.NotNull(result); + Assert.Equal("no-expiry-key", result.Key); + Assert.Null(result.Expiration); + await _cache.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task RemoveAsync_RemovesGrantFromCache() + { + // Act + await _sut.RemoveAsync("remove-key"); + + // Assert + await _cache.Received(1).RemoveAsync("remove-key"); + } + + [Fact] + public async Task GetAllAsync_ReturnsEmptyCollection() + { + // Arrange + var filter = new PersistedGrantFilter + { + SubjectId = "test-subject", + SessionId = "test-session", + ClientId = "test-client", + Type = "authorization_code" + }; + + // Act + var result = await _sut.GetAllAsync(filter); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task RemoveAllAsync_CompletesWithoutError() + { + // Arrange + var filter = new PersistedGrantFilter + { + SubjectId = "test-subject", + ClientId = "test-client" + }; + + // Act & Assert - should not throw + await _sut.RemoveAllAsync(filter); + + // Verify no cache operations were performed + await _cache.DidNotReceive().RemoveAsync(Arg.Any()); + } + + [Fact] + public async Task StoreAsync_PreservesAllGrantProperties() + { + // Arrange + var grant = new PersistedGrant + { + Key = "full-grant-key", + Type = "authorization_code", + SubjectId = "user-123", + SessionId = "session-456", + ClientId = "client-789", + Description = "Test grant", + CreationTime = DateTime.UtcNow.AddMinutes(-1), + Expiration = DateTime.UtcNow.AddMinutes(5), + ConsumedTime = null, + Data = "{\"test\":\"data\"}" + }; + + PersistedGrant? capturedGrant = null; + await _cache.SetAsync( + Arg.Any(), + Arg.Do(g => capturedGrant = g), + Arg.Any()); + + // Act + await _sut.StoreAsync(grant); + + // Assert + Assert.NotNull(capturedGrant); + Assert.Equal(grant.Key, capturedGrant.Key); + Assert.Equal(grant.Type, capturedGrant.Type); + Assert.Equal(grant.SubjectId, capturedGrant.SubjectId); + Assert.Equal(grant.SessionId, capturedGrant.SessionId); + Assert.Equal(grant.ClientId, capturedGrant.ClientId); + Assert.Equal(grant.Description, capturedGrant.Description); + Assert.Equal(grant.CreationTime, capturedGrant.CreationTime); + Assert.Equal(grant.Expiration, capturedGrant.Expiration); + Assert.Equal(grant.ConsumedTime, capturedGrant.ConsumedTime); + Assert.Equal(grant.Data, capturedGrant.Data); + } + + private static PersistedGrant CreateTestGrant(string key, DateTime? expiration) + { + return new PersistedGrant + { + Key = key, + Type = "authorization_code", + SubjectId = "test-subject", + ClientId = "test-client", + CreationTime = DateTime.UtcNow, + Expiration = expiration, + Data = "{\"test\":\"data\"}" + }; + } +} diff --git a/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj b/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj index 4fc79f2025..d0d329397c 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj +++ b/bitwarden_license/test/Scim.IntegrationTest/Scim.IntegrationTest.csproj @@ -1,35 +1,37 @@ - - - - false - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - true - PreserveNewest - Never - - - + + + + false + + $(WarningsNotAsErrors);CA1305 + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + true + PreserveNewest + Never + + + diff --git a/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs new file mode 100644 index 0000000000..7a1c9f9628 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs @@ -0,0 +1,952 @@ +using System.Net; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Sso.IntegrationTest.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Bitwarden.License.Test.Sso.IntegrationTest.Utilities; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using NSubstitute; +using Xunit; +using AuthenticationSchemes = Bit.Core.AuthenticationSchemes; + +namespace Bit.Sso.IntegrationTest.Controllers; + +public class AccountControllerTests(SsoApplicationFactory factory) : IClassFixture +{ + private readonly SsoApplicationFactory _factory = factory; + + /* + * Test to verify the /Account/ExternalCallback endpoint exists and is reachable. + */ + [Fact] + public async Task ExternalCallback_EndpointExists_ReturnsExpectedStatusCode() + { + // Arrange + var client = _factory.CreateClient(); + + // Act - Verify the endpoint is accessible (even if it fails due to missing auth) + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - The endpoint should exist and return 500 (not 404) due to missing authentication + Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); + } + + /* + * Test to verify calling /Account/ExternalCallback without an authentication cookie + * results in an error as expected. + */ + [Fact] + public async Task ExternalCallback_WithNoAuthenticationCookie_ReturnsError() + { + // Arrange + var client = _factory.CreateClient(); + + // Act - Call ExternalCallback without proper authentication setup + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because there's no external authentication cookie + Assert.False(response.IsSuccessStatusCode); + // The endpoint will throw an exception when authentication fails + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify behavior of /Account/ExternalCallback with PM24579 feature flag + */ + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ExternalCallback_WithPM24579FeatureFlag_AndNoAuthCookie_ReturnsError + ( + bool featureFlagEnabled + ) + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(featureFlagEnabled); + services.AddSingleton(featureService); + }); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify behavior of /Account/ExternalCallback simulating failed authentication. + */ + [Fact] + public async Task ExternalCallback_WithMockedAuthenticationService_FailedAuth_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithFailedAuthentication() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert + Assert.False(response.IsSuccessStatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when SSO config exists but is disabled. + */ + [Fact] + public async Task ExternalCallback_WithDisabledSsoConfig_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig(ssoConfig => ssoConfig!.Enabled = false) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because SSO config is disabled + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + [Fact] + public async Task ExternalCallback_FindUserFromExternalProviderAsync_OrganizationOrSsoConfigNotFound_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user has invalid status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when SSO config expects an ACR value + * but the authentication response has a missing or invalid ACR claim. + */ + [Fact] + public async Task ExternalCallback_WithExpectedAcrValue_AndInvalidAcr_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig(ssoConfig => ssoConfig!.SetData( + new SsoConfigurationData + { + ExpectedReturnAcrValue = "urn:expected:acr:value" + })) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because ACR claim is missing or invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Expected authentication context class reference (acr) was not returned with the authentication response or is invalid", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when the authentication response + * does not contain any recognizable user ID claim (sub, NameIdentifier, uid, upn, eppn). + */ + [Fact] + public async Task ExternalCallback_WithNoUserIdClaim_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .OmitProviderUserId() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); ; + + // Assert - Should fail because no user ID claim was found + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Unknown userid", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when no email claim is found + * and the providerUserId cannot be used as a fallback email (doesn't contain @). + */ + [Fact] + public async Task ExternalCallback_WithNoEmailClaim_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithNullEmail() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because no email claim was found + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Cannot find email claim", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * uses Key Connector but has no org user record (was removed from organization). + */ + [Fact] + public async Task ExternalCallback_WithExistingKeyConnectorUser_AndNoOrgUser_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser(user => + { + user.UsesKeyConnector = true; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user uses Key Connector but has no org user record + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * uses Key Connector and has an org user record in the invited status. + */ + [Fact] + public async Task ExternalCallback_WithExistingKeyConnectorUser_AndInvitedOrgUser_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig(ssoConfig => { }) + .WithUser(user => + { + user.UsesKeyConnector = true; + }) + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Invited; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user uses Key Connector but the Org user is in the invited status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * (not using Key Connector) has no org user record - they were removed from the organization. + */ + [Fact] + public async Task ExternalCallback_WithExistingUser_AndNoOrgUser_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user exists but has no org user record + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("You were removed from the organization managing single sign-on for your account. Contact the organization administrator", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * has an org user record with Invited status - they must accept the invite first. + */ + [Fact] + public async Task ExternalCallback_WithExistingUser_AndInvitedOrgUserStatus_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Invited; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user must accept invite before using SSO + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("you must first log in using your master password", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when organization has no available seats + * and cannot auto-scale because it's a self-hosted instance. + */ + [Fact] + public async Task ExternalCallback_WithNoAvailableSeats_OnSelfHosted_ReturnsError() + { + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithOrganization(org => + { + org.Seats = 5; // Organization has seat limit + }) + .AsSelfHosted() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because no seats available and cannot auto-scale on self-hosted + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("No seats available for organization", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when organization has no available seats + * and auto-scaling fails (e.g., billing issue, max seats reached). + */ + [Fact] + public async Task ExternalCallback_WithNoAvailableSeats_AndAutoAddSeatsFails_ReturnsError() + { + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithOrganization(org => + { + org.Seats = 5; + org.MaxAutoscaleSeats = 5; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because auto-adding seats failed + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("No seats available for organization", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when email cannot be found + * during new user provisioning (Scenario 2) after bypassing the first email check + * via manual linking path (userIdentifier is set). + */ + [Fact] + public async Task ExternalCallback_WithUserIdentifier_AndNoEmail_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUserIdentifier("") + .WithNullEmail() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because email cannot be found during new user provisioning + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Cannot find email claim", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when org user has an unknown/invalid status. + * This tests defensive code that handles future enum values or data corruption scenarios. + * We simulate this by casting an invalid integer to OrganizationUserStatusType. + */ + [Fact] + public async Task ExternalCallback_WithUnknownOrgUserStatus_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = (OrganizationUserStatusType)99; // Invalid enum value - simulates future status or data corruption + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because org user status is unknown/invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("is in an unknown state", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + // Note: "User should be found ln 304" appears to be unreachable defensive code. + // CreateUserAndOrgUserConditionallyAsync always returns a non-null user or throws an exception, + // so possibleSsoLinkedUser cannot be null when the feature flag check executes. + + /* + * Test to verify /Account/ExternalCallback returns error when userIdentifier + * is malformed (doesn't contain comma separator for userId,token format). + * There is only a single test case here but in the future we may need to expand the + * tests to cover other invalid formats. + */ + [Theory] + [BitAutoData("No-Comas-Identifier")] + public async Task ExternalCallback_WithInvalidUserIdentifierFormat_ReturnsError( + string UserIdentifier + ) + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUserIdentifier(UserIdentifier) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because userIdentifier format is invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Invalid user identifier", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when userIdentifier + * contains valid userId but invalid/mismatched token. + * + * NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because: + * - The userIdentifier in the auth result must contain a userId that matches a user in the system + * - User.SetNewId() always overwrites the Id (unlike Organization.SetNewId() which has a guard) + * - This means we cannot pre-set a User.Id before database insertion + * - The auth mock must be configured BEFORE accessing factory.Services (required by SubstituteService) + * - Therefore, we cannot coordinate the userId between the auth mock and the seeded user + * - Using substitutes allows us to control the exact userId and mock UserManager.VerifyUserTokenAsync + */ + [Fact] + public async Task ExternalCallback_WithUserIdentifier_AndInvalidToken_ReturnsError() + { + // Arrange + var organizationId = Guid.NewGuid(); + var providerUserId = Guid.NewGuid().ToString(); + var userId = Guid.NewGuid(); + var testEmail = "test_user@integration.test"; + var testName = "Test User"; + // Valid format but token won't verify + var userIdentifier = $"{userId},invalid-token"; + + var claimedUser = new User + { + Id = userId, + Email = testEmail, + Name = testName + }; + + var organization = new Organization + { + Id = organizationId, + Name = "Test Organization", + Enabled = true, + UseSso = true + }; + + var ssoConfig = new SsoConfig + { + OrganizationId = organizationId, + Enabled = true + }; + ssoConfig.SetData(new SsoConfigurationData()); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); + services.AddSingleton(featureService); + + // Mock organization repository + var orgRepo = Substitute.For(); + orgRepo.GetByIdAsync(organizationId).Returns(organization); + orgRepo.GetByIdentifierAsync(organizationId.ToString()).Returns(organization); + services.AddSingleton(orgRepo); + + // Mock SSO config repository + var ssoConfigRepo = Substitute.For(); + ssoConfigRepo.GetByOrganizationIdAsync(organizationId).Returns(ssoConfig); + services.AddSingleton(ssoConfigRepo); + + // Mock user repository - no existing user via SSO + var userRepo = Substitute.For(); + userRepo.GetBySsoUserAsync(providerUserId, organizationId).Returns((User?)null); + services.AddSingleton(userRepo); + + // Mock user service - returns user for manual linking lookup + var userService = Substitute.For(); + userService.GetUserByIdAsync(userId.ToString()).Returns(claimedUser); + services.AddSingleton(userService); + + // Mock UserManager to return false for token verification + var userManager = Substitute.For>( + Substitute.For>(), null, null, null, null, null, null, null, null); + userManager.VerifyUserTokenAsync( + claimedUser, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + services.AddSingleton(userManager); + + // Mock authentication service with userIdentifier that has valid format but invalid token + var authService = Substitute.For(); + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(MockSuccessfulAuthResult.Build(organizationId, providerUserId, testEmail, testName, null, userIdentifier)); + services.AddSingleton(authService); + }); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because token verification failed + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Supplied userId and token did not match", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is enabled. + */ + [Fact] + public async Task ExternalCallback_WithRevokedOrgUser_WithPM24579FeatureFlagEnabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Revoked; + }) + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user state is invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains( + $"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.", + stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is disabled. + */ + [Fact] + public async Task ExternalCallback_WithRevokedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Revoked; + }) + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user has invalid status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains( + $"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.", + stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error for invited org user when PM24579 feature flag is disabled. + */ + [Fact] + public async Task ExternalCallback_WithInvitedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Invited; + }) + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user has invalid status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains( + $"To accept your invite to {testData.Organization?.DisplayName()}, you must first log in using your master password. Once your invite has been accepted, you will be able to log in using SSO.", + stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + + /* + * Test to verify /Account/ExternalCallback returns error when user is found via SSO + * but has no organization user record (with feature flag enabled). + */ + [Fact] + public async Task ExternalCallback_WithSsoUser_AndNoOrgUser_WithFeatureFlagEnabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithSsoUser() + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because org user cannot be found + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Could not find organization user", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when the provider scheme + * is not a valid GUID (SSOProviderIsNotAnOrgId). + * + * NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because: + * - Organization.Id is of type Guid and cannot be set to a non-GUID value + * - The auth mock scheme must be a non-GUID string to trigger this error path + * - This cannot be tested since ln 438 in AccountController.FindUserFromExternalProviderAsync throws a different exception + * before reaching the organization lookup exception. + */ + [Fact(Skip = "This test cannot be executed because the organization ID must be a GUID. See note in test summary.")] + public async Task ExternalCallback_WithInvalidProviderGuid_ReturnsError() + { + // Arrange + var invalidScheme = "not-a-valid-guid"; + var providerUserId = Guid.NewGuid().ToString(); + var testEmail = "test@example.com"; + var testName = "Test User"; + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + // Mock authentication service with invalid (non-GUID) scheme + var authService = Substitute.For(); + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(MockSuccessfulAuthResult.Build(invalidScheme, providerUserId, testEmail, testName)); + services.AddSingleton(authService); + }); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because provider is not a valid organization GUID + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Organization not found from identifier.", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when the organization ID + * in the auth result does not match any organization in the database. + * NOTE: This code path is unreachable because the SsoConfig must exist to proceed, but there is a circular dependency: + * - SsoConfig cannot exist without a valid Organization but the test is testing that an Organization cannot be found. + */ + [Fact(Skip = "This code path is unreachable because the SsoConfig must exist to proceed. But the SsoConfig cannot exist without a valid Organization.")] + public async Task ExternalCallback_WithNonExistentOrganization_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithNonExistentOrganizationInAuth() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because organization cannot be found by the ID in auth result + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Could not find organization", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing + * SSO-linked user logs in (user exists in SsoUser table). + */ + [Fact] + public async Task ExternalCallback_WithExistingSsoUser_ReturnsSuccess() + { + // Arrange - User with SSO link already exists + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser() + .WithSsoUser() + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when JIT provisioning + * a new user (user doesn't exist, gets created automatically). + */ + [Fact] + public async Task ExternalCallback_WithJitProvisioning_ReturnsSuccess() + { + // Arrange - No user, no org user - JIT provisioning will create both + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user + * with a valid (Confirmed) organization user status logs in via SSO for the first time. + */ + [Fact] + public async Task ExternalCallback_WithExistingUserAndConfirmedOrgUser_ReturnsSuccess() + { + // Arrange - Existing user with confirmed org user status, no SSO link yet + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Confirmed; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user + * with Accepted organization user status logs in via SSO. + */ + [Fact] + public async Task ExternalCallback_WithExistingUserAndAcceptedOrgUser_ReturnsSuccess() + { + // Arrange - Existing user with accepted org user status + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Accepted; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback returns a View with 200 status + * when the client is a native application (uses custom URI scheme like "bitwarden://callback"). + * Native clients get a different response for better UX - a 200 with redirect view instead of 302. + * See AccountController lines 371-378. + */ + [Fact] + public async Task ExternalCallback_WithNativeClient_ReturnsViewWith200Status() + { + // Arrange - Existing SSO user with native client context + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser() + .WithSsoUser() + .AsNativeClient() + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Native clients get 200 status with a redirect view instead of 302 + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // The Location header should be empty for native clients (set in controller) + // and the response should contain the redirect view + var content = await response.Content.ReadAsStringAsync(); + Assert.NotEmpty(content); // View content should be present + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Properties/launchSettings.json b/bitwarden_license/test/Sso.IntegrationTest/Properties/launchSettings.json new file mode 100644 index 0000000000..63637a5304 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Sso.IntegrationTest": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59973;http://localhost:59974" + } + } +} \ No newline at end of file diff --git a/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj b/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj new file mode 100644 index 0000000000..42d0743d51 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj @@ -0,0 +1,41 @@ + + + + net8.0 + enable + enable + + false + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + true + PreserveNewest + Never + + + + \ No newline at end of file diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoApplicationFactory.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoApplicationFactory.cs new file mode 100644 index 0000000000..656c045284 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoApplicationFactory.cs @@ -0,0 +1,11 @@ +using Bit.IntegrationTestCommon.Factories; + +namespace Bit.Sso.IntegrationTest.Utilities; + +public class SsoApplicationFactory : WebApplicationFactoryBase +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs new file mode 100644 index 0000000000..95f2387af2 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs @@ -0,0 +1,327 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Bitwarden.License.Test.Sso.IntegrationTest.Utilities; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authentication; +using NSubstitute; +using AuthenticationSchemes = Bit.Core.AuthenticationSchemes; + +namespace Bit.Sso.IntegrationTest.Utilities; + +/// +/// Contains the factory and all entities created by for use in integration tests. +/// +public record SsoTestData( + SsoApplicationFactory Factory, + Organization? Organization, + User? User, + OrganizationUser? OrganizationUser, + SsoConfig? SsoConfig, + SsoUser? SsoUser); + +/// +/// Builder for creating SSO test data with seeded database entities. +/// +public class SsoTestDataBuilder +{ + /// + /// This UserIdentifier is a mock for the UserIdentifier we get from the External Identity Provider. + /// + private string? _userIdentifier; + private Action? _organizationConfig; + private Action? _userConfig; + private Action? _orgUserConfig; + private Action? _ssoConfigConfig; + private Action? _ssoUserConfig; + private Action? _featureFlagConfig; + + private bool _includeUser = false; + private bool _includeSsoUser = false; + private bool _includeOrganizationUser = false; + private bool _includeSsoConfig = false; + private bool _successfulAuth = true; + private bool _withNullEmail = false; + private bool _isSelfHosted = false; + private bool _includeProviderUserId = true; + private bool _useNonExistentOrgInAuth = false; + private bool _isNativeClient = false; + + public SsoTestDataBuilder WithOrganization(Action configure) + { + _organizationConfig = configure; + return this; + } + + public SsoTestDataBuilder WithUser(Action? configure = null) + { + _includeUser = true; + _userConfig = configure; + return this; + } + + public SsoTestDataBuilder WithOrganizationUser(Action? configure = null) + { + _includeOrganizationUser = true; + _orgUserConfig = configure; + return this; + } + + public SsoTestDataBuilder WithSsoConfig(Action? configure = null) + { + _includeSsoConfig = true; + _ssoConfigConfig = configure; + return this; + } + + public SsoTestDataBuilder WithSsoUser(Action? configure = null) + { + _includeSsoUser = true; + _ssoUserConfig = configure; + return this; + } + + public SsoTestDataBuilder WithFeatureFlags(Action configure) + { + _featureFlagConfig = configure; + return this; + } + + public SsoTestDataBuilder WithFailedAuthentication() + { + _successfulAuth = false; + return this; + } + + public SsoTestDataBuilder WithNullEmail() + { + _withNullEmail = true; + return this; + } + + public SsoTestDataBuilder WithUserIdentifier(string userIdentifier) + { + _userIdentifier = userIdentifier; + return this; + } + + public SsoTestDataBuilder OmitProviderUserId() + { + _includeProviderUserId = false; + return this; + } + + public SsoTestDataBuilder AsSelfHosted() + { + _isSelfHosted = true; + return this; + } + + /// + /// Causes the auth result to use a different (non-existent) organization ID than what is seeded + /// in the database. This simulates the "organization not found" scenario. + /// + public SsoTestDataBuilder WithNonExistentOrganizationInAuth() + { + _useNonExistentOrgInAuth = true; + return this; + } + + /// + /// Configures the test to simulate a native client (non-browser) OIDC flow. + /// Native clients use custom URI schemes (e.g., "bitwarden://callback") instead of http/https. + /// This causes ExternalCallback to return a View with 200 status instead of a redirect. + /// + public SsoTestDataBuilder AsNativeClient() + { + _isNativeClient = true; + return this; + } + + public async Task BuildAsync() + { + // Create factory + var factory = new SsoApplicationFactory(); + + // Pre-generate IDs and values needed for auth mock (before accessing Services) + var organizationId = Guid.NewGuid(); + // Use a different org ID in auth if testing "organization not found" scenario + var authOrganizationId = _useNonExistentOrgInAuth ? Guid.NewGuid() : organizationId; + var providerUserId = _includeProviderUserId ? Guid.NewGuid().ToString() : ""; + var userEmail = _withNullEmail ? null : $"user_{Guid.NewGuid()}@test.com"; + var userName = "TestUser"; + + // 1. Configure mocked authentication service BEFORE accessing Services + factory.SubstituteService(authService => + { + if (_successfulAuth) + { + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(MockSuccessfulAuthResult.Build( + authOrganizationId, + providerUserId, + userEmail, + userName, + acrValue: null, + _userIdentifier)); + } + else + { + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(AuthenticateResult.Fail("External authentication error")); + } + }); + + // 1.a Configure GlobalSettings for Self-Hosted and seat limit + factory.SubstituteService(globalSettings => + { + globalSettings.SelfHosted.Returns(_isSelfHosted); + }); + + // 1.b configure setting feature flags + _featureFlagConfig?.Invoke(factory); + + // 1.c Configure IIdentityServerInteractionService for native client flow + if (_isNativeClient) + { + factory.SubstituteService(interaction => + { + // Native clients have redirect URIs that don't start with http/https + // e.g., "bitwarden://callback" or "com.bitwarden.app://callback" + var authorizationRequest = new AuthorizationRequest + { + RedirectUri = "bitwarden://sso-callback" + }; + interaction.GetAuthorizationContextAsync(Arg.Any()) + .Returns(authorizationRequest); + }); + } + + if (!_successfulAuth) + { + return new SsoTestData(factory, null!, null!, null!, null!, null!); + } + + // 2. Create Organization with defaults (using pre-generated ID) + var organization = new Organization + { + Id = organizationId, + Name = "Test Organization", + BillingEmail = "billing@test.com", + Plan = "Enterprise", + Enabled = true, + UseSso = true + }; + _organizationConfig?.Invoke(organization); + + var orgRepo = factory.Services.GetRequiredService(); + organization = await orgRepo.CreateAsync(organization); + + // 3. Create User with defaults (using pre-generated values) + User? user = null; + if (_includeUser) + { + user = new User + { + Email = userEmail ?? $"email_{Guid.NewGuid()}@test.dev", + Name = userName, + ApiKey = Guid.NewGuid().ToString(), + SecurityStamp = Guid.NewGuid().ToString() + }; + _userConfig?.Invoke(user); + + var userRepo = factory.Services.GetRequiredService(); + user = await userRepo.CreateAsync(user); + } + + // 4. Create OrganizationUser linking them + OrganizationUser? orgUser = null; + if (_includeOrganizationUser) + { + orgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user!.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User + }; + _orgUserConfig?.Invoke(orgUser); + + var orgUserRepo = factory.Services.GetRequiredService(); + orgUser = await orgUserRepo.CreateAsync(orgUser); + } + + // 4.a Create many OrganizationUser to test seat count logic + if (organization.Seats > 1) + { + var orgUserRepo = factory.Services.GetRequiredService(); + var userRepo = factory.Services.GetRequiredService(); + var additionalOrgUsers = new List(); + for (var i = 1; i <= organization.Seats; i++) + { + var additionalUser = new User + { + Email = $"additional_user_{i}_{Guid.NewGuid()}@test.dev", + Name = $"AdditionalUser{i}", + ApiKey = Guid.NewGuid().ToString(), + SecurityStamp = Guid.NewGuid().ToString() + }; + var createdAdditionalUser = await userRepo.CreateAsync(additionalUser); + + var additionalOrgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = createdAdditionalUser.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User + }; + additionalOrgUsers.Add(additionalOrgUser); + } + await orgUserRepo.CreateManyAsync(additionalOrgUsers); + } + + // 5. Create SsoConfig, if ssoConfigConfig is not null + SsoConfig? ssoConfig = null; + if (_includeSsoConfig) + { + ssoConfig = new SsoConfig + { + OrganizationId = authOrganizationId, + Enabled = true + }; + ssoConfig.SetData(new SsoConfigurationData()); + _ssoConfigConfig?.Invoke(ssoConfig); + + var ssoConfigRepo = factory.Services.GetRequiredService(); + ssoConfig = await ssoConfigRepo.CreateAsync(ssoConfig); + } + + // 6. Optionally create SsoUser (using pre-generated providerUserId as ExternalId) + SsoUser? ssoUser = null; + if (_includeSsoUser) + { + ssoUser = new SsoUser + { + OrganizationId = organization.Id, + UserId = user!.Id, + ExternalId = providerUserId + }; + _ssoUserConfig?.Invoke(ssoUser); + + var ssoUserRepo = factory.Services.GetRequiredService(); + ssoUser = await ssoUserRepo.CreateAsync(ssoUser); + } + + return new SsoTestData(factory, organization, user, orgUser, ssoConfig, ssoUser); + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SuccessfulAuthResult.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SuccessfulAuthResult.cs new file mode 100644 index 0000000000..72f5738ad9 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SuccessfulAuthResult.cs @@ -0,0 +1,88 @@ +using System.Security.Claims; +using Bit.Core; +using Duende.IdentityModel; +using Microsoft.AspNetCore.Authentication; + +namespace Bitwarden.License.Test.Sso.IntegrationTest.Utilities; + +/// +/// Creates a mock for use in tests requiring a valid external authentication result. +/// +internal static class MockSuccessfulAuthResult +{ + /// + /// Since this tests the external Authentication flow, only the OrganizationId is strictly required. + /// However, some tests may require additional claims to be present, so they can be optionally added. + /// + /// + /// + /// + /// + /// + /// + /// + public static AuthenticateResult Build( + Guid organizationId, + string? providerUserId, + string? email, + string? name = null, + string? acrValue = null, + string? userIdentifier = null) + { + return Build(organizationId.ToString(), providerUserId, email, name, acrValue, userIdentifier); + } + + /// + /// Overload that accepts a custom scheme string. Useful for testing invalid provider scenarios + /// where the scheme is not a valid GUID. + /// + public static AuthenticateResult Build( + string scheme, + string? providerUserId, + string? email, + string? name = null, + string? acrValue = null, + string? userIdentifier = null) + { + var claims = new List(); + + if (!string.IsNullOrEmpty(email)) + { + claims.Add(new Claim(JwtClaimTypes.Email, email)); + } + + if (!string.IsNullOrEmpty(providerUserId)) + { + claims.Add(new Claim(JwtClaimTypes.Subject, providerUserId)); + } + + if (!string.IsNullOrEmpty(name)) + { + claims.Add(new Claim(JwtClaimTypes.Name, name)); + } + + if (!string.IsNullOrEmpty(acrValue)) + { + claims.Add(new Claim(JwtClaimTypes.AuthenticationContextClassReference, acrValue)); + } + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "External")); + var properties = new AuthenticationProperties + { + Items = + { + ["scheme"] = scheme, + ["return_url"] = "~/", + ["state"] = "test-state", + ["user_identifier"] = userIdentifier ?? string.Empty + } + }; + + var ticket = new AuthenticationTicket( + principal, + properties, + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); + + return AuthenticateResult.Success(ticket); + } +} diff --git a/dev/.env.example b/dev/.env.example index f31b5b9eeb..88fbd44036 100644 --- a/dev/.env.example +++ b/dev/.env.example @@ -34,4 +34,5 @@ RABBITMQ_DEFAULT_PASS=SET_A_PASSWORD_HERE_123 # SETUP_AZURITE=yes # RUN_MSSQL_MIGRATIONS=yes # DEV_CERT_PASSWORD=dev_cert_password_here +# DEV_CERT_CONTENTS=base64_encoded_dev_pfx_here (alternative to placing dev.pfx file manually) # INSTALL_STRIPE_CLI=no diff --git a/dev/.gitignore b/dev/.gitignore index 39b657f453..034b002f7c 100644 --- a/dev/.gitignore +++ b/dev/.gitignore @@ -18,3 +18,4 @@ signingkey.jwk # Reverse Proxy Conifg reverse-proxy.conf +*.crt diff --git a/dev/secrets.json.example b/dev/secrets.json.example index 0d4213aec1..7bf753e938 100644 --- a/dev/secrets.json.example +++ b/dev/secrets.json.example @@ -39,6 +39,14 @@ }, "licenseDirectory": "", "enableNewDeviceVerification": true, - "enableEmailVerification": true + "enableEmailVerification": true, + "communication": { + "bootstrap": "none", + "ssoCookieVendor": { + "idpLoginUrl": "", + "cookieName": "", + "cookieDomain": "" + } + } } } diff --git a/dev/setup_secrets.ps1 b/dev/setup_secrets.ps1 old mode 100644 new mode 100755 index 96dff04632..a41890bc46 --- a/dev/setup_secrets.ps1 +++ b/dev/setup_secrets.ps1 @@ -2,7 +2,7 @@ # Helper script for applying the same user secrets to each project param ( [switch]$clear, - [Parameter(ValueFromRemainingArguments = $true, Position=1)] + [Parameter(ValueFromRemainingArguments = $true, Position = 1)] $cmdArgs ) @@ -16,17 +16,19 @@ if ($clear -eq $true) { } $projects = @{ - Admin = "../src/Admin" - Api = "../src/Api" - Billing = "../src/Billing" - Events = "../src/Events" - EventsProcessor = "../src/EventsProcessor" - Icons = "../src/Icons" - Identity = "../src/Identity" - Notifications = "../src/Notifications" - Sso = "../bitwarden_license/src/Sso" - Scim = "../bitwarden_license/src/Scim" + Admin = "../src/Admin" + Api = "../src/Api" + Billing = "../src/Billing" + Events = "../src/Events" + EventsProcessor = "../src/EventsProcessor" + Icons = "../src/Icons" + Identity = "../src/Identity" + Notifications = "../src/Notifications" + Sso = "../bitwarden_license/src/Sso" + Scim = "../bitwarden_license/src/Scim" IntegrationTests = "../test/Infrastructure.IntegrationTest" + SeederApi = "../util/SeederApi" + SeederUtility = "../util/DbSeederUtility" } foreach ($key in $projects.keys) { diff --git a/dev/verify_migrations.ps1 b/dev/verify_migrations.ps1 index d63c34f2bd..ce1754e684 100644 --- a/dev/verify_migrations.ps1 +++ b/dev/verify_migrations.ps1 @@ -5,12 +5,19 @@ Validates that new database migration files follow naming conventions and chronological order. .DESCRIPTION - This script validates migration files in util/Migrator/DbScripts/ to ensure: + This script validates migration files to ensure: + + For SQL migrations in util/Migrator/DbScripts/: 1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql 2. New migrations are chronologically ordered (filename sorts after existing migrations) 3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5) 4. A 2-digit sequence number is included (e.g., _00, _01) + For Entity Framework migrations in util/MySqlMigrations, util/PostgresMigrations, util/SqliteMigrations: + 1. New migrations follow the naming format: YYYYMMDDHHMMSS_Description.cs + 2. Each migration has both .cs and .Designer.cs files + 3. New migrations are chronologically ordered (timestamp sorts after existing migrations) + .PARAMETER BaseRef The base git reference to compare against (e.g., 'main', 'HEAD~1') @@ -41,7 +48,7 @@ $migrationPath = "util/Migrator/DbScripts" # Get list of migrations from base reference try { - $baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/*.sql" 2>$null | Sort-Object + $baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/" 2>$null | Where-Object { $_ -like "*.sql" } | Sort-Object if ($LASTEXITCODE -ne 0) { Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'" $baseMigrations = @() @@ -53,80 +60,293 @@ catch { } # Get list of migrations from current reference -$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/*.sql" | Sort-Object +$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/" | Where-Object { $_ -like "*.sql" } | Sort-Object # Find added migrations $addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations } +$sqlValidationFailed = $false + if ($addedMigrations.Count -eq 0) { - Write-Host "No new migration files added." - exit 0 + Write-Host "No new SQL migration files added." + Write-Host "" +} +else { + Write-Host "New SQL migration files detected:" + $addedMigrations | ForEach-Object { Write-Host " $_" } + Write-Host "" + + # Get the last migration from base reference + if ($baseMigrations.Count -eq 0) { + Write-Host "No previous SQL migrations found (initial commit?). Skipping chronological validation." + Write-Host "" + } + else { + $lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1) + Write-Host "Last SQL migration in base reference: $lastBaseMigration" + Write-Host "" + + # Required format regex: YYYY-MM-DD_NN_Description.sql + $formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$' + + foreach ($migration in $addedMigrations) { + $migrationName = Split-Path -Leaf $migration + + # Validate NEW migration filename format + if ($migrationName -notmatch $formatRegex) { + Write-Host "ERROR: Migration '$migrationName' does not match required format" + Write-Host "Required format: YYYY-MM-DD_NN_Description.sql" + Write-Host " - YYYY: 4-digit year" + Write-Host " - MM: 2-digit month with leading zero (01-12)" + Write-Host " - DD: 2-digit day with leading zero (01-31)" + Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)" + Write-Host "Example: 2025-01-15_00_MyMigration.sql" + $sqlValidationFailed = $true + continue + } + + # Compare migration name with last base migration (using ordinal string comparison) + if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) { + Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'" + $sqlValidationFailed = $true + } + else { + Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'" + } + } + + Write-Host "" + } + + if ($sqlValidationFailed) { + Write-Host "FAILED: One or more SQL migrations are incorrectly named or not in chronological order" + Write-Host "" + Write-Host "All new SQL migration files must:" + Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql" + Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)" + Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)" + Write-Host " 4. Have a filename that sorts after the last migration in base" + Write-Host "" + Write-Host "To fix this issue:" + Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/" + Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql" + Write-Host " 3. Ensure the date is after $lastBaseMigration" + Write-Host "" + Write-Host "Example: 2025-01-15_00_AddNewFeature.sql" + } + else { + Write-Host "SUCCESS: All new SQL migrations are correctly named and in chronological order" + } + + Write-Host "" } -Write-Host "New migration files detected:" -$addedMigrations | ForEach-Object { Write-Host " $_" } +# =========================================================================================== +# Validate Entity Framework Migrations +# =========================================================================================== + +Write-Host "===================================================================" +Write-Host "Validating Entity Framework Migrations" +Write-Host "===================================================================" Write-Host "" -# Get the last migration from base reference -if ($baseMigrations.Count -eq 0) { - Write-Host "No previous migrations found (initial commit?). Skipping validation." - exit 0 -} +$efMigrationPaths = @( + @{ Path = "util/MySqlMigrations/Migrations"; Name = "MySQL" }, + @{ Path = "util/PostgresMigrations/Migrations"; Name = "Postgres" }, + @{ Path = "util/SqliteMigrations/Migrations"; Name = "SQLite" } +) -$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1) -Write-Host "Last migration in base reference: $lastBaseMigration" -Write-Host "" +$efValidationFailed = $false -# Required format regex: YYYY-MM-DD_NN_Description.sql -$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$' +foreach ($migrationPathInfo in $efMigrationPaths) { + $efPath = $migrationPathInfo.Path + $dbName = $migrationPathInfo.Name -$validationFailed = $false + Write-Host "-------------------------------------------------------------------" + Write-Host "Checking $dbName EF migrations in $efPath" + Write-Host "-------------------------------------------------------------------" + Write-Host "" -foreach ($migration in $addedMigrations) { - $migrationName = Split-Path -Leaf $migration + # Get list of migrations from base reference + try { + $baseMigrations = git ls-tree -r --name-only $BaseRef -- "$efPath/" 2>$null | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object + if ($LASTEXITCODE -ne 0) { + Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'" + $baseMigrations = @() + } + } + catch { + Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'" + $baseMigrations = @() + } - # Validate NEW migration filename format - if ($migrationName -notmatch $formatRegex) { - Write-Host "ERROR: Migration '$migrationName' does not match required format" - Write-Host "Required format: YYYY-MM-DD_NN_Description.sql" - Write-Host " - YYYY: 4-digit year" - Write-Host " - MM: 2-digit month with leading zero (01-12)" - Write-Host " - DD: 2-digit day with leading zero (01-31)" - Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)" - Write-Host "Example: 2025-01-15_00_MyMigration.sql" - $validationFailed = $true + # Get list of migrations from current reference + $currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$efPath/" | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object + + # Find added migrations + $addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations } + + if ($addedMigrations.Count -eq 0) { + Write-Host "No new $dbName EF migration files added." + Write-Host "" continue } - # Compare migration name with last base migration (using ordinal string comparison) - if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) { - Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'" - $validationFailed = $true + Write-Host "New $dbName EF migration files detected:" + $addedMigrations | ForEach-Object { Write-Host " $_" } + Write-Host "" + + # Get the last migration from base reference + if ($baseMigrations.Count -eq 0) { + Write-Host "No previous $dbName migrations found. Skipping chronological validation." + Write-Host "" } else { - Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'" + $lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1) + Write-Host "Last $dbName migration in base reference: $lastBaseMigration" + Write-Host "" } + + # Required format regex: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs + $efFormatRegex = '^[0-9]{14}_.+\.cs$' + + # Group migrations by base name (without .Designer.cs suffix) + $migrationGroups = @{} + $unmatchedFiles = @() + + foreach ($migration in $addedMigrations) { + $migrationName = Split-Path -Leaf $migration + + # Extract base name (remove .Designer.cs or .cs) + if ($migrationName -match '^([0-9]{14}_.+?)(?:\.Designer)?\.cs$') { + $baseName = $matches[1] + if (-not $migrationGroups.ContainsKey($baseName)) { + $migrationGroups[$baseName] = @() + } + $migrationGroups[$baseName] += $migrationName + } + else { + # Track files that don't match the expected pattern + $unmatchedFiles += $migrationName + } + } + + # Flag any files that don't match the expected pattern + if ($unmatchedFiles.Count -gt 0) { + Write-Host "ERROR: The following migration files do not match the required format:" + foreach ($unmatchedFile in $unmatchedFiles) { + Write-Host " - $unmatchedFile" + } + Write-Host "" + Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs" + Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)" + Write-Host " - Description: Descriptive name using PascalCase" + Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs" + Write-Host "" + $efValidationFailed = $true + } + + foreach ($baseName in $migrationGroups.Keys | Sort-Object) { + $files = $migrationGroups[$baseName] + + # Validate format + $mainFile = "$baseName.cs" + $designerFile = "$baseName.Designer.cs" + + if ($mainFile -notmatch $efFormatRegex) { + Write-Host "ERROR: Migration '$mainFile' does not match required format" + Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs" + Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)" + Write-Host "Example: 20250115120000_AddNewFeature.cs" + $efValidationFailed = $true + continue + } + + # Check that both .cs and .Designer.cs files exist + $hasCsFile = $files -contains $mainFile + $hasDesignerFile = $files -contains $designerFile + + if (-not $hasCsFile) { + Write-Host "ERROR: Missing main migration file: $mainFile" + $efValidationFailed = $true + } + + if (-not $hasDesignerFile) { + Write-Host "ERROR: Missing designer file: $designerFile" + Write-Host "Each EF migration must have both a .cs and .Designer.cs file" + $efValidationFailed = $true + } + + if (-not $hasCsFile -or -not $hasDesignerFile) { + continue + } + + # Compare migration timestamp with last base migration (using ordinal string comparison) + if ($baseMigrations.Count -gt 0) { + if ([string]::CompareOrdinal($mainFile, $lastBaseMigration) -lt 0) { + Write-Host "ERROR: New migration '$mainFile' is not chronologically after '$lastBaseMigration'" + $efValidationFailed = $true + } + else { + Write-Host "OK: '$mainFile' is chronologically after '$lastBaseMigration'" + } + } + else { + Write-Host "OK: '$mainFile' (no previous migrations to compare)" + } + } + + Write-Host "" +} + +if ($efValidationFailed) { + Write-Host "FAILED: One or more EF migrations are incorrectly named or not in chronological order" + Write-Host "" + Write-Host "All new EF migration files must:" + Write-Host " 1. Follow the naming format: YYYYMMDDHHMMSS_Description.cs" + Write-Host " 2. Include both .cs and .Designer.cs files" + Write-Host " 3. Have a timestamp that sorts after the last migration in base" + Write-Host "" + Write-Host "To fix this issue:" + Write-Host " 1. Locate your migration file(s) in the respective Migrations directory" + Write-Host " 2. Ensure both .cs and .Designer.cs files exist" + Write-Host " 3. Rename to follow format: YYYYMMDDHHMMSS_Description.cs" + Write-Host " 4. Ensure the timestamp is after the last migration" + Write-Host "" + Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs" +} +else { + Write-Host "SUCCESS: All new EF migrations are correctly named and in chronological order" } Write-Host "" +Write-Host "===================================================================" +Write-Host "Validation Summary" +Write-Host "===================================================================" + +if ($sqlValidationFailed -or $efValidationFailed) { + if ($sqlValidationFailed) { + Write-Host "❌ SQL migrations validation FAILED" + } + else { + Write-Host "✓ SQL migrations validation PASSED" + } + + if ($efValidationFailed) { + Write-Host "❌ EF migrations validation FAILED" + } + else { + Write-Host "✓ EF migrations validation PASSED" + } -if ($validationFailed) { - Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order" Write-Host "" - Write-Host "All new migration files must:" - Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql" - Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)" - Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)" - Write-Host " 4. Have a filename that sorts after the last migration in base" - Write-Host "" - Write-Host "To fix this issue:" - Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/" - Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql" - Write-Host " 3. Ensure the date is after $lastBaseMigration" - Write-Host "" - Write-Host "Example: 2025-01-15_00_AddNewFeature.sql" + Write-Host "OVERALL RESULT: FAILED" exit 1 } - -Write-Host "SUCCESS: All new migrations are correctly named and in chronological order" -exit 0 +else { + Write-Host "✓ SQL migrations validation PASSED" + Write-Host "✓ EF migrations validation PASSED" + Write-Host "" + Write-Host "OVERALL RESULT: SUCCESS" + exit 0 +} diff --git a/src/Admin/Admin.csproj b/src/Admin/Admin.csproj index cd30e841b4..b815ddea82 100644 --- a/src/Admin/Admin.csproj +++ b/src/Admin/Admin.csproj @@ -2,6 +2,8 @@ bitwarden-Admin + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index cd370e3898..1dbab08ca6 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -496,6 +496,7 @@ public class OrganizationsController : Controller organization.UseOrganizationDomains = model.UseOrganizationDomains; organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies; organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation; + organization.UseDisableSmAdsForUsers = model.UseDisableSmAdsForUsers; organization.UsePhishingBlocker = model.UsePhishingBlocker; //secrets diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index 4fff85e1e8..77a8496e5b 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -107,6 +107,7 @@ public class OrganizationEditModel : OrganizationViewModel MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts; UseOrganizationDomains = org.UseOrganizationDomains; UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation; + UseDisableSmAdsForUsers = org.UseDisableSmAdsForUsers; UsePhishingBlocker = org.UsePhishingBlocker; _plans = plans; @@ -196,6 +197,8 @@ public class OrganizationEditModel : OrganizationViewModel public int? MaxAutoscaleSmServiceAccounts { get; set; } [Display(Name = "Use Organization Domains")] public bool UseOrganizationDomains { get; set; } + [Display(Name = "Disable SM Ads For Users")] + public new bool UseDisableSmAdsForUsers { get; set; } [Display(Name = "Automatic User Confirmation")] public bool UseAutomaticUserConfirmation { get; set; } @@ -330,6 +333,7 @@ public class OrganizationEditModel : OrganizationViewModel existingOrganization.SmServiceAccounts = SmServiceAccounts; existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts; existingOrganization.UseOrganizationDomains = UseOrganizationDomains; + existingOrganization.UseDisableSmAdsForUsers = UseDisableSmAdsForUsers; existingOrganization.UsePhishingBlocker = UsePhishingBlocker; return existingOrganization; } diff --git a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs index 457686be53..652535b592 100644 --- a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs @@ -76,6 +76,7 @@ public class OrganizationViewModel public bool UseSecretsManager => Organization.UseSecretsManager; public bool UseRiskInsights => Organization.UseRiskInsights; public bool UsePhishingBlocker => Organization.UsePhishingBlocker; + public bool UseDisableSmAdsForUsers => Organization.UseDisableSmAdsForUsers; public IEnumerable OwnersDetails { get; set; } public IEnumerable AdminsDetails { get; set; } } diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index b22859ed60..0b60d99e3c 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -185,6 +185,13 @@ + @if (FeatureService.IsEnabled(FeatureFlagKeys.SM1719_RemoveSecretsManagerAds)) + { +
+ + +
+ }

Access Intelligence

diff --git a/src/Admin/HostedServices/DatabaseMigrationHostedService.cs b/src/Admin/HostedServices/DatabaseMigrationHostedService.cs index 219e6846bd..51739ce655 100644 --- a/src/Admin/HostedServices/DatabaseMigrationHostedService.cs +++ b/src/Admin/HostedServices/DatabaseMigrationHostedService.cs @@ -1,5 +1,5 @@ -using Bit.Core.Utilities; -using Microsoft.Data.SqlClient; +using System.Data.Common; +using Bit.Core.Utilities; namespace Bit.Admin.HostedServices; @@ -30,7 +30,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable // TODO: Maybe flip a flag somewhere to indicate migration is complete?? break; } - catch (SqlException e) + catch (DbException e) { if (i >= maxMigrationAttempts) { @@ -40,7 +40,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable else { _logger.LogError(e, - "Database unavailable for migration. Trying again (attempt #{0})...", i + 1); + "Database unavailable for migration. Trying again (attempt #{AttemptNumber})...", i + 1); await Task.Delay(20000, cancellationToken); } } diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 87d68a7ac6..6c0a644ee6 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -65,6 +65,7 @@ public class Startup default: break; } + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/src/Admin/appsettings.Production.json b/src/Admin/appsettings.Production.json index 9f797f3111..1d852abfed 100644 --- a/src/Admin/appsettings.Production.json +++ b/src/Admin/appsettings.Production.json @@ -20,11 +20,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index 6e0f78e1e6..e851daac36 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.93.2", + "sass": "1.97.2", "sass-loader": "16.0.5", - "webpack": "5.102.1", + "webpack": "5.104.1", "webpack-cli": "5.1.4" } }, @@ -750,9 +750,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", - "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", + "version": "2.9.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz", + "integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -793,9 +793,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -814,11 +814,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -835,9 +835,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001751", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", - "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "version": "1.0.30001763", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", + "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", "dev": true, "funding": [ { @@ -989,9 +989,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.237", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", - "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true, "license": "ISC" }, @@ -1023,9 +1023,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -1419,13 +1419,17 @@ } }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/locate-path": { @@ -1542,9 +1546,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.26", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", - "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -1875,9 +1879,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.93.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", - "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "version": "1.97.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz", + "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", "dev": true, "license": "MIT", "peer": true, @@ -2110,9 +2114,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2174,9 +2178,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -2226,9 +2230,9 @@ } }, "node_modules/webpack": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", - "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", "peer": true, @@ -2241,21 +2245,21 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", + "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, diff --git a/src/Admin/package.json b/src/Admin/package.json index f6f21e2cf9..3a3926d6ee 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.93.2", + "sass": "1.97.2", "sass-loader": "16.0.5", - "webpack": "5.102.1", + "webpack": "5.104.1", "webpack-cli": "5.1.4" } } diff --git a/src/Api/AdminConsole/Authorization/Requirements/MemberRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/MemberRequirement.cs new file mode 100644 index 0000000000..ed205524d1 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Requirements/MemberRequirement.cs @@ -0,0 +1,14 @@ +using Bit.Core.Context; + +namespace Bit.Api.AdminConsole.Authorization.Requirements; + +/// +/// Requires that the user is a member of the organization. +/// +public class MemberRequirement : IOrganizationRequirement +{ + public Task AuthorizeAsync( + CurrentContextOrganization? organizationClaims, + Func> isProviderUserForOrg) + => Task.FromResult(organizationClaims is not null); +} diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index a380d2f0d9..37b58bc252 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -19,6 +19,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed 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.OrganizationUsers.SelfRevokeUser; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; @@ -56,7 +57,7 @@ public class OrganizationUsersController : BaseAdminConsoleController private readonly ICollectionRepository _collectionRepository; private readonly IGroupRepository _groupRepository; private readonly IUserService _userService; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly ICurrentContext _currentContext; private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery; private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; @@ -81,6 +82,7 @@ public class OrganizationUsersController : BaseAdminConsoleController private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand; private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand; + private readonly ISelfRevokeOrganizationUserCommand _selfRevokeOrganizationUserCommand; public OrganizationUsersController(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -88,7 +90,7 @@ public class OrganizationUsersController : BaseAdminConsoleController ICollectionRepository collectionRepository, IGroupRepository groupRepository, IUserService userService, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, ICurrentContext currentContext, ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, @@ -112,7 +114,8 @@ public class OrganizationUsersController : BaseAdminConsoleController IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand, IAdminRecoverAccountCommand adminRecoverAccountCommand, IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand, - V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext) + V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext, + ISelfRevokeOrganizationUserCommand selfRevokeOrganizationUserCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -120,7 +123,7 @@ public class OrganizationUsersController : BaseAdminConsoleController _collectionRepository = collectionRepository; _groupRepository = groupRepository; _userService = userService; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _currentContext = currentContext; _countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; @@ -145,6 +148,7 @@ public class OrganizationUsersController : BaseAdminConsoleController _initPendingOrganizationCommand = initPendingOrganizationCommand; _revokeOrganizationUserCommand = revokeOrganizationUserCommand; _adminRecoverAccountCommand = adminRecoverAccountCommand; + _selfRevokeOrganizationUserCommand = selfRevokeOrganizationUserCommand; } [HttpGet("{id}")] @@ -283,14 +287,7 @@ public class OrganizationUsersController : BaseAdminConsoleController var userId = _userService.GetProperUserId(User); IEnumerable> result; - if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud)) - { - result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids); - } - else - { - result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids); - } + result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids); return new ListResponseModel( result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2))); @@ -353,10 +350,9 @@ public class OrganizationUsersController : BaseAdminConsoleController return false; } - var masterPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); - var useMasterPasswordPolicy = masterPasswordPolicy != null && - masterPasswordPolicy.Enabled && - masterPasswordPolicy.GetDataModel().AutoEnrollEnabled; + var masterPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword); + var useMasterPasswordPolicy = masterPasswordPolicy.Enabled && + masterPasswordPolicy.GetDataModel().AutoEnrollEnabled; return useMasterPasswordPolicy; } @@ -635,6 +631,20 @@ public class OrganizationUsersController : BaseAdminConsoleController await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync); } + [HttpPut("revoke-self")] + [Authorize] + public async Task RevokeSelfAsync(Guid orgId) + { + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new UnauthorizedAccessException(); + } + + var result = await _selfRevokeOrganizationUserCommand.SelfRevokeUserAsync(orgId, userId.Value); + return Handle(result); + } + [HttpPatch("{id}/revoke")] [Obsolete("This endpoint is deprecated. Use PUT method instead")] [Authorize] @@ -647,11 +657,6 @@ public class OrganizationUsersController : BaseAdminConsoleController [Authorize] public async Task> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2)) - { - return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); - } - var currentUserId = _userService.GetProperUserId(User); if (currentUserId == null) { @@ -684,7 +689,16 @@ public class OrganizationUsersController : BaseAdminConsoleController [Authorize] public async Task RestoreAsync(Guid orgId, Guid id) { - await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId)); + await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId, null)); + } + + + [HttpPut("{id}/restore/vnext")] + [Authorize] + [RequireFeature(FeatureFlagKeys.DefaultUserCollectionRestore)] + public async Task RestoreAsync_vNext(Guid orgId, Guid id, [FromBody] OrganizationUserRestoreRequest request) + { + await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId, request.DefaultUserCollectionName)); } [HttpPatch("{id}/restore")] @@ -699,7 +713,9 @@ public class OrganizationUsersController : BaseAdminConsoleController [Authorize] public async Task> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); + return await RestoreOrRevokeUsersAsync(orgId, model, + (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, + restoringUserId, _userService, model.DefaultUserCollectionName)); } [HttpPatch("restore")] diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 100cd7caf6..a6de8c521f 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -48,7 +48,7 @@ public class OrganizationsController : Controller { private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IOrganizationService _organizationService; private readonly IUserService _userService; private readonly ICurrentContext _currentContext; @@ -74,7 +74,7 @@ public class OrganizationsController : Controller public OrganizationsController( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IOrganizationService organizationService, IUserService userService, ICurrentContext currentContext, @@ -99,7 +99,7 @@ public class OrganizationsController : Controller { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _organizationService = organizationService; _userService = userService; _currentContext = currentContext; @@ -183,15 +183,14 @@ public class OrganizationsController : Controller return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id)); } - var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); - if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null) + var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword); + if (!resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null) { return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false); } var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase); return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false); - } [HttpPost("")] diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index ae1d12e887..fe3600c3dd 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -7,8 +7,6 @@ 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; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -44,6 +42,7 @@ public class PoliciesController : Controller private readonly IUserService _userService; private readonly ISavePolicyCommand _savePolicyCommand; private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; + private readonly IPolicyQuery _policyQuery; public PoliciesController(IPolicyRepository policyRepository, IOrganizationUserRepository organizationUserRepository, @@ -55,7 +54,8 @@ public class PoliciesController : Controller IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IOrganizationRepository organizationRepository, ISavePolicyCommand savePolicyCommand, - IVNextSavePolicyCommand vNextSavePolicyCommand) + IVNextSavePolicyCommand vNextSavePolicyCommand, + IPolicyQuery policyQuery) { _policyRepository = policyRepository; _organizationUserRepository = organizationUserRepository; @@ -69,27 +69,24 @@ public class PoliciesController : Controller _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; _savePolicyCommand = savePolicyCommand; _vNextSavePolicyCommand = vNextSavePolicyCommand; + _policyQuery = policyQuery; } [HttpGet("{type}")] - public async Task Get(Guid orgId, int type) + public async Task Get(Guid orgId, PolicyType type) { if (!await _currentContext.ManagePolicies(orgId)) { throw new NotFoundException(); } - var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type); - if (policy == null) - { - return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type }); - } + var policy = await _policyQuery.RunAsync(orgId, type); if (policy.Type is PolicyType.SingleOrg) { - return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery); + return await policy.GetSingleOrgPolicyStatusResponseAsync(_organizationHasVerifiedDomainsQuery); } - return new PolicyDetailResponseModel(policy); + return new PolicyStatusResponseModel(policy); } [HttpGet("")] @@ -212,7 +209,6 @@ public class PoliciesController : Controller } [HttpPut("{type}/vnext")] - [RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)] [Authorize] public async Task PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model) { diff --git a/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs b/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs index 46b253da31..3a2ada719f 100644 --- a/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs @@ -2,11 +2,13 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request; public class OrganizationDomainRequestModel { [Required] + [DomainNameValidator] public string DomainName { get; set; } } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs index b7a4db3acd..06fe654b73 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs @@ -116,12 +116,17 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel public string ResetPasswordKey { get; set; } public string MasterPasswordHash { get; set; } } - +#nullable enable public class OrganizationUserBulkRequestModel { [Required, MinLength(1)] - public IEnumerable Ids { get; set; } + public IEnumerable Ids { get; set; } = new List(); + + [EncryptedString] + [EncryptedStringLength(1000)] + public string? DefaultUserCollectionName { get; set; } } +#nullable disable public class ResetPasswordWithOrgIdRequestModel : OrganizationUserResetPasswordEnrollmentRequestModel { diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRestoreRequest.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRestoreRequest.cs new file mode 100644 index 0000000000..ff5f877b3a --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRestoreRequest.cs @@ -0,0 +1,13 @@ +using Bit.Core.Utilities; + +namespace Bit.Api.AdminConsole.Models.Request.Organizations; + +public class OrganizationUserRestoreRequest +{ + /// + /// This is the encrypted default collection name to be used for restored users if required + /// + [EncryptedString] + [EncryptedStringLength(1000)] + public string? DefaultUserCollectionName { get; set; } +} diff --git a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs index f5ef468b4e..c3378cd11d 100644 --- a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs @@ -48,6 +48,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation; UseSecretsManager = organizationDetails.UseSecretsManager; UsePhishingBlocker = organizationDetails.UsePhishingBlocker; + UseDisableSMAdsForUsers = organizationDetails.UseDisableSMAdsForUsers; UsePasswordManager = organizationDetails.UsePasswordManager; SelfHost = organizationDetails.SelfHost; Seats = organizationDetails.Seats; @@ -100,6 +101,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UseDisableSMAdsForUsers { get; set; } public bool UsePhishingBlocker { get; set; } public bool SelfHost { get; set; } public int? Seats { get; set; } diff --git a/src/Api/AdminConsole/Models/Response/Helpers/PolicyDetailResponses.cs b/src/Api/AdminConsole/Models/Response/Helpers/PolicyStatusResponses.cs similarity index 66% rename from src/Api/AdminConsole/Models/Response/Helpers/PolicyDetailResponses.cs rename to src/Api/AdminConsole/Models/Response/Helpers/PolicyStatusResponses.cs index dded6a4c89..da08cdef0f 100644 --- a/src/Api/AdminConsole/Models/Response/Helpers/PolicyDetailResponses.cs +++ b/src/Api/AdminConsole/Models/Response/Helpers/PolicyStatusResponses.cs @@ -1,19 +1,21 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; namespace Bit.Api.AdminConsole.Models.Response.Helpers; -public static class PolicyDetailResponses +public static class PolicyStatusResponses { - public static async Task GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery) + public static async Task GetSingleOrgPolicyStatusResponseAsync( + this PolicyStatus policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery) { if (policy.Type is not PolicyType.SingleOrg) { throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy)); } - return new PolicyDetailResponseModel(policy, await CanToggleState()); + + return new PolicyStatusResponseModel(policy, await CanToggleState()); async Task CanToggleState() { @@ -25,5 +27,4 @@ public static class PolicyDetailResponses return !policy.Enabled; } } - } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 9a3543f4bb..46c5a50b6c 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -74,6 +74,7 @@ public class OrganizationResponseModel : ResponseModel UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation; + UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers; UsePhishingBlocker = organization.UsePhishingBlocker; } @@ -124,6 +125,7 @@ public class OrganizationResponseModel : ResponseModel public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UseDisableSmAdsForUsers { get; set; } public bool UsePhishingBlocker { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/PolicyDetailResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/PolicyDetailResponseModel.cs deleted file mode 100644 index cb5560e689..0000000000 --- a/src/Api/AdminConsole/Models/Response/Organizations/PolicyDetailResponseModel.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Bit.Core.AdminConsole.Entities; - -namespace Bit.Api.AdminConsole.Models.Response.Organizations; - -public class PolicyDetailResponseModel : PolicyResponseModel -{ - public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj) - { - } - - public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy) - { - CanToggleState = canToggleState; - } - - /// - /// Indicates whether the Policy can be enabled/disabled - /// - public bool CanToggleState { get; set; } = true; -} diff --git a/src/Api/AdminConsole/Models/Response/Organizations/PolicyStatusResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/PolicyStatusResponseModel.cs new file mode 100644 index 0000000000..8c93302a17 --- /dev/null +++ b/src/Api/AdminConsole/Models/Response/Organizations/PolicyStatusResponseModel.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Models.Api; + +namespace Bit.Api.AdminConsole.Models.Response.Organizations; + +public class PolicyStatusResponseModel : ResponseModel +{ + public PolicyStatusResponseModel(PolicyStatus policy, bool canToggleState = true) : base("policy") + { + OrganizationId = policy.OrganizationId; + Type = policy.Type; + + if (!string.IsNullOrWhiteSpace(policy.Data)) + { + Data = JsonSerializer.Deserialize>(policy.Data) ?? new(); + } + + Enabled = policy.Enabled; + CanToggleState = canToggleState; + } + + public Guid OrganizationId { get; init; } + public PolicyType Type { get; init; } + public Dictionary Data { get; init; } = new(); + public bool Enabled { get; init; } + + /// + /// Indicates whether the Policy can be enabled/disabled + /// + public bool CanToggleState { get; init; } +} diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 58e5db18c2..220c812cae 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -2,12 +2,16 @@ using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; +using Bit.Core.AdminConsole.Models.Data; 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.OrganizationUsers.RevokeUser.v2; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Services; using Bit.Core.Context; +using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; @@ -30,6 +34,8 @@ public class MembersController : Controller private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; + private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2; + private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; public MembersController( IOrganizationUserRepository organizationUserRepository, @@ -42,7 +48,9 @@ public class MembersController : Controller IOrganizationRepository organizationRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, - IResendOrganizationInviteCommand resendOrganizationInviteCommand) + IResendOrganizationInviteCommand resendOrganizationInviteCommand, + IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2, + IRestoreOrganizationUserCommand restoreOrganizationUserCommand) { _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; @@ -55,6 +63,8 @@ public class MembersController : Controller _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; + _revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2; + _restoreOrganizationUserCommand = restoreOrganizationUserCommand; } /// @@ -258,4 +268,59 @@ public class MembersController : Controller await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id); return new OkResult(); } + + /// + /// Revoke a member's access to an organization. + /// + /// The ID of the member to be revoked. + [HttpPost("{id}/revoke")] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task Revoke(Guid id) + { + var organizationUser = await _organizationUserRepository.GetByIdAsync(id); + if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId) + { + return new NotFoundResult(); + } + + var request = new RevokeOrganizationUsersRequest( + _currentContext.OrganizationId!.Value, + [id], + new SystemUser(EventSystemUser.PublicApi) + ); + + var results = await _revokeOrganizationUserCommandV2.RevokeUsersAsync(request); + var result = results.Single(); + + return result.Result.Match( + error => new BadRequestObjectResult(new ErrorResponseModel(error.Message)), + _ => new OkResult() + ); + } + + /// + /// Restore a member. + /// + /// + /// Restores a previously revoked member of the organization. + /// + /// The identifier of the member to be restored. + [HttpPost("{id}/restore")] + [ProducesResponseType((int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] + [ProducesResponseType((int)HttpStatusCode.NotFound)] + public async Task Restore(Guid id) + { + var organizationUser = await _organizationUserRepository.GetByIdAsync(id); + if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId) + { + return new NotFoundResult(); + } + + await _restoreOrganizationUserCommand.RestoreUserAsync(organizationUser, EventSystemUser.PublicApi); + + return new OkResult(); + } } diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index dd27de2e63..d25b989d11 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -4,6 +4,8 @@ false bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml true + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 839d00f7a1..1223e2f05f 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -38,7 +38,9 @@ public class AccountsController : Controller private readonly IProviderUserRepository _providerUserRepository; private readonly IUserService _userService; private readonly IPolicyService _policyService; + private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1; private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; + private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IFeatureService _featureService; @@ -54,6 +56,8 @@ public class AccountsController : Controller IUserService userService, IPolicyService policyService, ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand, + ISetInitialMasterPasswordCommandV1 setInitialMasterPasswordCommandV1, + ITdeSetPasswordCommand tdeSetPasswordCommand, ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IFeatureService featureService, @@ -69,6 +73,8 @@ public class AccountsController : Controller _userService = userService; _policyService = policyService; _setInitialMasterPasswordCommand = setInitialMasterPasswordCommand; + _setInitialMasterPasswordCommandV1 = setInitialMasterPasswordCommandV1; + _tdeSetPasswordCommand = tdeSetPasswordCommand; _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _featureService = featureService; @@ -208,7 +214,7 @@ public class AccountsController : Controller } [HttpPost("set-password")] - public async Task PostSetPasswordAsync([FromBody] SetPasswordRequestModel model) + public async Task PostSetPasswordAsync([FromBody] SetInitialPasswordRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -216,33 +222,48 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - try + if (model.IsV2Request()) { - user = model.ToUser(user); + if (model.IsTdeSetPasswordRequest()) + { + await _tdeSetPasswordCommand.SetMasterPasswordAsync(user, model.ToData()); + } + else + { + await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, model.ToData()); + } } - catch (Exception e) + else { - ModelState.AddModelError(string.Empty, e.Message); + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 + try + { + user = model.ToUser(user); + } + catch (Exception e) + { + ModelState.AddModelError(string.Empty, e.Message); + throw new BadRequestException(ModelState); + } + + var result = await _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync( + user, + model.MasterPasswordHash, + model.Key, + model.OrgIdentifier); + + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + throw new BadRequestException(ModelState); } - - var result = await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync( - user, - model.MasterPasswordHash, - model.Key, - model.OrgIdentifier); - - if (result.Succeeded) - { - return; - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - throw new BadRequestException(ModelState); } [HttpPost("verify-password")] diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 016cd82fe2..bd87e82c8a 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -7,7 +7,7 @@ using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Response; -using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs b/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs index c67cb9db3f..f58eaafaab 100644 --- a/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs +++ b/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs @@ -1,7 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Jobs; using Quartz; diff --git a/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs b/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs index f23774f060..63b861d920 100644 --- a/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs +++ b/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs @@ -1,7 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Jobs; using Quartz; diff --git a/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs new file mode 100644 index 0000000000..55ffdca94b --- /dev/null +++ b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs @@ -0,0 +1,160 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Models.Requests; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.Utilities; + +namespace Bit.Api.Auth.Models.Request.Accounts; + +public class SetInitialPasswordRequestModel : IValidatableObject +{ + // TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27327 + [Obsolete("Use MasterPasswordAuthentication instead")] + [StringLength(300)] + public string? MasterPasswordHash { get; set; } + + [Obsolete("Use MasterPasswordUnlock instead")] + public string? Key { get; set; } + + [Obsolete("Use AccountKeys instead")] + public KeysRequestModel? Keys { get; set; } + + [Obsolete("Use MasterPasswordAuthentication instead")] + public KdfType? Kdf { get; set; } + + [Obsolete("Use MasterPasswordAuthentication instead")] + public int? KdfIterations { get; set; } + + [Obsolete("Use MasterPasswordAuthentication instead")] + public int? KdfMemory { get; set; } + + [Obsolete("Use MasterPasswordAuthentication instead")] + public int? KdfParallelism { get; set; } + + public MasterPasswordAuthenticationDataRequestModel? MasterPasswordAuthentication { get; set; } + public MasterPasswordUnlockDataRequestModel? MasterPasswordUnlock { get; set; } + public AccountKeysRequestModel? AccountKeys { get; set; } + + [StringLength(50)] + public string? MasterPasswordHint { get; set; } + + [Required] + public required string OrgIdentifier { get; set; } + + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 + public User ToUser(User existingUser) + { + existingUser.MasterPasswordHint = MasterPasswordHint; + existingUser.Kdf = Kdf!.Value; + existingUser.KdfIterations = KdfIterations!.Value; + existingUser.KdfMemory = KdfMemory; + existingUser.KdfParallelism = KdfParallelism; + existingUser.Key = Key; + Keys?.ToUser(existingUser); + return existingUser; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (IsV2Request()) + { + // V2 registration + + // Validate Kdf + var authenticationKdf = MasterPasswordAuthentication!.Kdf.ToData(); + var unlockKdf = MasterPasswordUnlock!.Kdf.ToData(); + + // Currently, KDF settings are not saved separately for authentication and unlock and must therefore be equal + if (!authenticationKdf.Equals(unlockKdf)) + { + yield return new ValidationResult("KDF settings must be equal for authentication and unlock.", + [$"{nameof(MasterPasswordAuthentication)}.{nameof(MasterPasswordAuthenticationDataRequestModel.Kdf)}", + $"{nameof(MasterPasswordUnlock)}.{nameof(MasterPasswordUnlockDataRequestModel.Kdf)}"]); + } + + var authenticationValidationErrors = KdfSettingsValidator.Validate(authenticationKdf).ToList(); + if (authenticationValidationErrors.Count != 0) + { + yield return authenticationValidationErrors.First(); + } + + var unlockValidationErrors = KdfSettingsValidator.Validate(unlockKdf).ToList(); + if (unlockValidationErrors.Count != 0) + { + yield return unlockValidationErrors.First(); + } + + yield break; + } + + // V1 registration + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 + if (string.IsNullOrEmpty(MasterPasswordHash)) + { + yield return new ValidationResult("MasterPasswordHash must be supplied."); + } + + if (string.IsNullOrEmpty(Key)) + { + yield return new ValidationResult("Key must be supplied."); + } + + if (Kdf == null) + { + yield return new ValidationResult("Kdf must be supplied."); + yield break; + } + + if (KdfIterations == null) + { + yield return new ValidationResult("KdfIterations must be supplied."); + yield break; + } + + if (Kdf == KdfType.Argon2id) + { + if (KdfMemory == null) + { + yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id."); + } + + if (KdfParallelism == null) + { + yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id."); + } + } + + var validationErrors = KdfSettingsValidator + .Validate(Kdf!.Value, KdfIterations!.Value, KdfMemory, KdfParallelism).ToList(); + if (validationErrors.Count != 0) + { + yield return validationErrors.First(); + } + } + + public bool IsV2Request() + { + // AccountKeys can be null for TDE users, so we don't check that here + return MasterPasswordAuthentication != null && MasterPasswordUnlock != null; + } + + public bool IsTdeSetPasswordRequest() + { + return AccountKeys == null; + } + + public SetInitialMasterPasswordDataModel ToData() + { + return new SetInitialMasterPasswordDataModel + { + MasterPasswordAuthentication = MasterPasswordAuthentication!.ToData(), + MasterPasswordUnlock = MasterPasswordUnlock!.ToData(), + OrgSsoIdentifier = OrgIdentifier, + AccountKeys = AccountKeys?.ToAccountKeysData(), + MasterPasswordHint = MasterPasswordHint + }; + } +} diff --git a/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs deleted file mode 100644 index 0d809c6c11..0000000000 --- a/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs +++ /dev/null @@ -1,40 +0,0 @@ -// 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; - -namespace Bit.Api.Auth.Models.Request.Accounts; - -public class SetPasswordRequestModel -{ - [Required] - [StringLength(300)] - public string MasterPasswordHash { get; set; } - [Required] - public string Key { get; set; } - [StringLength(50)] - public string MasterPasswordHint { get; set; } - public KeysRequestModel Keys { get; set; } - [Required] - public KdfType Kdf { get; set; } - [Required] - public int KdfIterations { get; set; } - public int? KdfMemory { get; set; } - public int? KdfParallelism { get; set; } - public string OrgIdentifier { get; set; } - - public User ToUser(User existingUser) - { - existingUser.MasterPasswordHint = MasterPasswordHint; - existingUser.Kdf = Kdf; - existingUser.KdfIterations = KdfIterations; - existingUser.KdfMemory = KdfMemory; - existingUser.KdfParallelism = KdfParallelism; - existingUser.Key = Key; - Keys?.ToUser(existingUser); - return existingUser; - } -} diff --git a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index 33a7e52791..75e96ebc66 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -36,7 +36,7 @@ public class EmergencyAccessUpdateRequestModel existingEmergencyAccess.KeyEncrypted = KeyEncrypted; } existingEmergencyAccess.Type = Type; - existingEmergencyAccess.WaitTimeDays = WaitTimeDays; + existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays; return existingEmergencyAccess; } } diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 5d3e095fdd..c90b927bee 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -3,13 +3,10 @@ using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Core; -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.KeyManagement.Queries.Interfaces; -using Bit.Core.Models.Business; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -22,60 +19,10 @@ namespace Bit.Api.Billing.Controllers; [Authorize("Application")] public class AccountsController( IUserService userService, - ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IUserAccountKeysQuery userAccountKeysQuery, IFeatureService featureService, ILicensingService licensingService) : Controller { - // TODO: Remove when pm-24996-implement-upgrade-from-free-dialog is removed - [HttpPost("premium")] - public async Task PostPremiumAsync( - PremiumRequestModel model, - [FromServices] GlobalSettings globalSettings) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var valid = model.Validate(globalSettings); - UserLicense? license = null; - if (valid && globalSettings.SelfHosted) - { - license = await ApiHelpers.ReadJsonFileFromBody(HttpContext, model.License); - } - - if (!valid && !globalSettings.SelfHosted && string.IsNullOrWhiteSpace(model.Country)) - { - throw new BadRequestException("Country is required."); - } - - if (!valid || (globalSettings.SelfHosted && license == null)) - { - throw new BadRequestException("Invalid license."); - } - - var result = await userService.SignUpPremiumAsync(user, model.PaymentToken, - model.PaymentMethodType!.Value, model.AdditionalStorageGb.GetValueOrDefault(0), license, - new TaxInfo { BillingAddressCountry = model.Country, BillingAddressPostalCode = model.PostalCode }); - - var userTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); - var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user); - var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id); - var accountKeys = await userAccountKeysQuery.Run(user); - - var profile = new ProfileResponseModel(user, accountKeys, null, null, null, userTwoFactorEnabled, - userHasPremiumFromOrganization, organizationIdsClaimingActiveUser); - return new PaymentResponseModel - { - UserProfile = profile, - PaymentIntentClientSecret = result.Item2, - Success = result.Item1 - }; - } - - // TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work. + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpGet("subscription")] public async Task GetSubscriptionAsync( [FromServices] GlobalSettings globalSettings, @@ -114,7 +61,7 @@ public class AccountsController( } } - // TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpPost("storage")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostStorageAsync([FromBody] StorageRequestModel model) @@ -171,7 +118,7 @@ public class AccountsController( user.IsExpired()); } - // TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page [HttpPost("reinstate-premium")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostReinstateAsync() @@ -184,10 +131,4 @@ public class AccountsController( await userService.ReinstatePremiumAsync(user); } - - private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) - { - var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId); - return organizationsClaimingUser.Select(o => o.Id); - } } diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index 7ca85d52a8..8a1467dfa2 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -6,7 +6,7 @@ using Bit.Api.Models.Response; using Bit.Api.Models.Response.Organizations; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -38,7 +38,7 @@ public class OrganizationSponsorshipsController : Controller private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand; private readonly ICurrentContext _currentContext; private readonly IUserService _userService; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IFeatureService _featureService; public OrganizationSponsorshipsController( @@ -55,7 +55,7 @@ public class OrganizationSponsorshipsController : Controller ICloudSyncSponsorshipsCommand syncSponsorshipsCommand, IUserService userService, ICurrentContext currentContext, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IFeatureService featureService) { _organizationSponsorshipRepository = organizationSponsorshipRepository; @@ -71,7 +71,7 @@ public class OrganizationSponsorshipsController : Controller _syncSponsorshipsCommand = syncSponsorshipsCommand; _userService = userService; _currentContext = currentContext; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _featureService = featureService; } @@ -81,10 +81,10 @@ public class OrganizationSponsorshipsController : Controller public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model) { var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId); - var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId, + var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId, PolicyType.FreeFamiliesSponsorshipPolicy); - if (freeFamiliesSponsorshipPolicy?.Enabled == true) + if (freeFamiliesSponsorshipPolicy.Enabled) { throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); } @@ -108,10 +108,10 @@ public class OrganizationSponsorshipsController : Controller [SelfHosted(NotSelfHostedOnly = true)] public async Task ResendSponsorshipOffer(Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName) { - var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId, + var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId, PolicyType.FreeFamiliesSponsorshipPolicy); - if (freeFamiliesSponsorshipPolicy?.Enabled == true) + if (freeFamiliesSponsorshipPolicy.Enabled) { throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); } @@ -138,9 +138,9 @@ public class OrganizationSponsorshipsController : Controller var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email); if (isValid && sponsorship.SponsoringOrganizationId.HasValue) { - var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value, + var policy = await _policyQuery.RunAsync(sponsorship.SponsoringOrganizationId.Value, PolicyType.FreeFamiliesSponsorshipPolicy); - isFreeFamilyPolicyEnabled = policy?.Enabled ?? false; + isFreeFamilyPolicyEnabled = policy.Enabled; } var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled); @@ -165,10 +165,10 @@ public class OrganizationSponsorshipsController : Controller throw new BadRequestException("Can only redeem sponsorship for an organization you own."); } - var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync( + var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync( model.SponsoredOrganizationId, PolicyType.FreeFamiliesSponsorshipPolicy); - if (freeFamiliesSponsorshipPolicy?.Enabled == true) + if (freeFamiliesSponsorshipPolicy.Enabled) { throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index 489241bd55..6c56d6db3a 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -7,6 +7,8 @@ using Bit.Core.Billing.Licenses.Queries; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Entities; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -21,11 +23,14 @@ namespace Bit.Api.Billing.Controllers.VNext; public class AccountBillingVNextController( ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand, + IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery, IGetCreditQuery getCreditQuery, IGetPaymentMethodQuery getPaymentMethodQuery, IGetUserLicenseQuery getUserLicenseQuery, + IReinstateSubscriptionCommand reinstateSubscriptionCommand, IUpdatePaymentMethodCommand updatePaymentMethodCommand, - IUpdatePremiumStorageCommand updatePremiumStorageCommand) : BaseBillingController + IUpdatePremiumStorageCommand updatePremiumStorageCommand, + IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController { [HttpGet("credit")] [InjectUser] @@ -70,7 +75,6 @@ public class AccountBillingVNextController( } [HttpPost("subscription")] - [RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)] [InjectUser] public async Task CreateSubscriptionAsync( [BindNever] User user, @@ -91,14 +95,45 @@ public class AccountBillingVNextController( return TypedResults.Ok(response); } - [HttpPut("storage")] + [HttpGet("subscription")] [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] [InjectUser] - public async Task UpdateStorageAsync( + public async Task GetSubscriptionAsync( + [BindNever] User user) + { + var subscription = await getBitwardenSubscriptionQuery.Run(user); + return TypedResults.Ok(subscription); + } + + [HttpPost("subscription/reinstate")] + [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] + [InjectUser] + public async Task ReinstateSubscriptionAsync( + [BindNever] User user) + { + var result = await reinstateSubscriptionCommand.Run(user); + return Handle(result); + } + + [HttpPut("subscription/storage")] + [RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)] + [InjectUser] + public async Task UpdateSubscriptionStorageAsync( [BindNever] User user, [FromBody] StorageUpdateRequest request) { var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb); return Handle(result); } + + [HttpPost("upgrade")] + [InjectUser] + public async Task UpgradePremiumToOrganizationAsync( + [BindNever] User user, + [FromBody] UpgradePremiumToOrganizationRequest request) + { + var (organizationName, key, planType) = request.ToDomain(); + var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType); + return Handle(result); + } } diff --git a/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs index b86f29bdbc..ccc1dd9734 100644 --- a/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs @@ -1,7 +1,6 @@ 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; @@ -20,7 +19,6 @@ public class SelfHostedAccountBillingVNextController( ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController { [HttpPost("license")] - [RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)] [InjectUser] public async Task UploadLicenseAsync( [BindNever] User user, diff --git a/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs b/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs new file mode 100644 index 0000000000..14375efc78 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; + +namespace Bit.Api.Billing.Models.Requests.Premium; + +public class UpgradePremiumToOrganizationRequest +{ + [Required] + public string OrganizationName { get; set; } = null!; + + [Required] + public string Key { get; set; } = null!; + + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public ProductTierType Tier { get; set; } + + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PlanCadenceType Cadence { get; set; } + + private PlanType PlanType => + 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 upgrade to an Organization subscription that isn't Families, Teams or Enterprise.") + }; + + public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType); +} diff --git a/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs b/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs index 0b18fc1e6f..fe0c8e9e17 100644 --- a/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs +++ b/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs @@ -13,7 +13,6 @@ public class StorageUpdateRequest : IValidatableObject /// Must be between 0 and the maximum allowed (minus base storage). /// [Required] - [Range(0, 99)] public short AdditionalStorageGb { get; set; } public IEnumerable Validate(ValidationContext validationContext) @@ -22,14 +21,14 @@ public class StorageUpdateRequest : IValidatableObject { yield return new ValidationResult( "Additional storage cannot be negative.", - new[] { nameof(AdditionalStorageGb) }); + [nameof(AdditionalStorageGb)]); } if (AdditionalStorageGb > 99) { yield return new ValidationResult( "Maximum additional storage is 99 GB.", - new[] { nameof(AdditionalStorageGb) }); + [nameof(AdditionalStorageGb)]); } } } diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs index d65dc8fcb7..4f70a1135f 100644 --- a/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs @@ -6,8 +6,11 @@ namespace Bit.Api.KeyManagement.Models.Requests; public class MasterPasswordAuthenticationDataRequestModel { public required KdfRequestModel Kdf { get; init; } + [Required] public required string MasterPasswordAuthenticationHash { get; init; } - [StringLength(256)] public required string Salt { get; init; } + [Required] + [StringLength(256)] + public required string Salt { get; init; } public MasterPasswordAuthenticationData ToData() { diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs index ce7a2b343f..e1d7863cae 100644 --- a/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs @@ -7,8 +7,12 @@ 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; } + [Required] + [EncryptedString] + public required string MasterKeyWrappedUserKey { get; init; } + [Required] + [StringLength(256)] + public required string Salt { get; init; } public MasterPasswordUnlockData ToData() { diff --git a/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs b/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs index 5023521fe3..835965e2d6 100644 --- a/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs +++ b/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs @@ -34,8 +34,7 @@ public class OrganizationUserRotationValidator : IRotationValidator o.ResetPasswordKey != null).ToList(); - + existing = existing.Where(o => !string.IsNullOrEmpty(o.ResetPasswordKey)).ToList(); foreach (var ou in existing) { diff --git a/src/Api/Models/Public/Response/ErrorResponseModel.cs b/src/Api/Models/Public/Response/ErrorResponseModel.cs index c5bb06d02e..a40b0c9569 100644 --- a/src/Api/Models/Public/Response/ErrorResponseModel.cs +++ b/src/Api/Models/Public/Response/ErrorResponseModel.cs @@ -1,7 +1,5 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Api.Models.Public.Response; @@ -46,13 +44,14 @@ public class ErrorResponseModel : IResponseModel { } public ErrorResponseModel(string errorKey, string errorValue) - : this(errorKey, new string[] { errorValue }) + : this(errorKey, [errorValue]) { } public ErrorResponseModel(string errorKey, IEnumerable errorValues) : this(new Dictionary> { { errorKey, errorValues } }) { } + [JsonConstructor] public ErrorResponseModel(string message, Dictionary> errors) { Message = message; @@ -70,10 +69,10 @@ public class ErrorResponseModel : IResponseModel /// /// The request model is invalid. [Required] - public string Message { get; set; } + public string Message { get; init; } /// /// If multiple errors occurred, they are listed in dictionary. Errors related to a specific /// request parameter will include a dictionary key describing that parameter. /// - public Dictionary> Errors { get; set; } + public Dictionary>? Errors { get; } } diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 32d12aa416..a357264081 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -10,6 +10,7 @@ using Bit.Core.Utilities; namespace Bit.Api.Models.Response; +// TODO: Remove with deletion of pm-29594-update-individual-subscription-page public class SubscriptionResponseModel : ResponseModel { diff --git a/src/Api/Public/Controllers/CollectionsController.cs b/src/Api/Public/Controllers/CollectionsController.cs index a567062a5e..28de4dc16d 100644 --- a/src/Api/Public/Controllers/CollectionsController.cs +++ b/src/Api/Public/Controllers/CollectionsController.cs @@ -67,8 +67,9 @@ public class CollectionsController : Controller { var collections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(_currentContext.OrganizationId.Value); - var collectionResponses = collections.Select(c => - new CollectionResponseModel(c.Item1, c.Item2.Groups)); + var collectionResponses = collections + .Where(c => c.Item1.Type != CollectionType.DefaultUserCollection) + .Select(c => new CollectionResponseModel(c.Item1, c.Item2.Groups)); var response = new ListResponseModel(collectionResponses); return new JsonResult(response); diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 2f16470cd4..acbc4a68fa 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -14,7 +14,6 @@ using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models.Request; using Bit.Core.Auth.Entities; using Bit.SharedWeb.Health; -using Microsoft.IdentityModel.Logging; using Microsoft.OpenApi.Models; using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -85,6 +84,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); @@ -237,8 +237,6 @@ public class Startup GlobalSettings globalSettings, ILogger logger) { - IdentityModelEventSource.ShowPII = true; - // Add general security headers app.UseMiddleware(); diff --git a/src/Api/Tools/Controllers/OrganizationExportController.cs b/src/Api/Tools/Controllers/OrganizationExportController.cs index dd039bc4a5..578220075a 100644 --- a/src/Api/Tools/Controllers/OrganizationExportController.cs +++ b/src/Api/Tools/Controllers/OrganizationExportController.cs @@ -1,6 +1,5 @@ using Bit.Api.Tools.Authorization; using Bit.Api.Tools.Models.Response; -using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -21,7 +20,6 @@ public class OrganizationExportController : Controller private readonly IAuthorizationService _authorizationService; private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly ICollectionRepository _collectionRepository; - private readonly IFeatureService _featureService; public OrganizationExportController( IUserService userService, @@ -36,7 +34,6 @@ public class OrganizationExportController : Controller _authorizationService = authorizationService; _organizationCiphersQuery = organizationCiphersQuery; _collectionRepository = collectionRepository; - _featureService = featureService; } [HttpGet("export")] @@ -46,33 +43,20 @@ public class OrganizationExportController : Controller VaultExportOperations.ExportWholeVault); 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 allOrganizationCiphers = + await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections( + organizationId); - var allCollections = await _collectionRepository - .GetManySharedCollectionsByOrganizationIdAsync( - 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)); - } + return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, + _globalSettings)); } if (canExportManaged.Succeeded) diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index 449d9573fd..61002a0168 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -5,9 +5,11 @@ using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; using Bit.Api.Utilities; using Bit.Core; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.UserFeatures.SendAccess; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Services; -using Bit.Core.Settings; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Repositories; @@ -22,7 +24,6 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Tools.Controllers; [Route("sends")] -[Authorize("Application")] public class SendsController : Controller { private readonly ISendRepository _sendRepository; @@ -31,11 +32,10 @@ public class SendsController : Controller private readonly ISendFileStorageService _sendFileStorageService; private readonly IAnonymousSendCommand _anonymousSendCommand; private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; - private readonly ISendOwnerQuery _sendOwnerQuery; - private readonly ILogger _logger; - private readonly GlobalSettings _globalSettings; + private readonly IFeatureService _featureService; + private readonly IPushNotificationService _pushNotificationService; public SendsController( ISendRepository sendRepository, @@ -46,7 +46,8 @@ public class SendsController : Controller ISendOwnerQuery sendOwnerQuery, ISendFileStorageService sendFileStorageService, ILogger logger, - GlobalSettings globalSettings) + IFeatureService featureService, + IPushNotificationService pushNotificationService) { _sendRepository = sendRepository; _userService = userService; @@ -56,10 +57,12 @@ public class SendsController : Controller _sendOwnerQuery = sendOwnerQuery; _sendFileStorageService = sendFileStorageService; _logger = logger; - _globalSettings = globalSettings; + _featureService = featureService; + _pushNotificationService = pushNotificationService; } #region Anonymous endpoints + [AllowAnonymous] [HttpPost("access/{id}")] public async Task Access(string id, [FromBody] SendAccessRequestModel model) @@ -73,21 +76,32 @@ public class SendsController : Controller var guid = new Guid(CoreHelpers.Base64UrlDecode(id)); var send = await _sendRepository.GetByIdAsync(guid); + if (send == null) { throw new BadRequestException("Could not locate send"); } + + /* This guard can be removed once feature flag is retired*/ + var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP); + if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null) + { + return new UnauthorizedResult(); + } + var sendAuthResult = await _sendAuthorizationService.AccessAsync(send, model.Password); if (sendAuthResult.Equals(SendAccessResult.PasswordRequired)) { return new UnauthorizedResult(); } + if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid)) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } + if (sendAuthResult.Equals(SendAccessResult.Denied)) { throw new NotFoundException(); @@ -99,6 +113,7 @@ public class SendsController : Controller var creator = await _userService.GetUserByIdAsync(send.UserId.Value); sendResponse.CreatorIdentifier = creator.Email; } + return new ObjectResult(sendResponse); } @@ -122,6 +137,13 @@ public class SendsController : Controller throw new BadRequestException("Could not locate send"); } + /* This guard can be removed once feature flag is retired*/ + var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP); + if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null) + { + return new UnauthorizedResult(); + } + var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, model.Password); @@ -129,21 +151,19 @@ public class SendsController : Controller { return new UnauthorizedResult(); } + if (result.Equals(SendAccessResult.PasswordInvalid)) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } + if (result.Equals(SendAccessResult.Denied)) { throw new NotFoundException(); } - return new ObjectResult(new SendFileDownloadDataResponseModel() - { - Id = fileId, - Url = url, - }); + return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url, }); } [AllowAnonymous] @@ -157,7 +177,8 @@ public class SendsController : Controller { try { - var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1]; + var blobName = + eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1]; var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName); var send = await _sendRepository.GetByIdAsync(new Guid(sendId)); if (send == null) @@ -166,6 +187,7 @@ public class SendsController : Controller { await azureSendFileStorageService.DeleteBlobAsync(blobName); } + return; } @@ -173,7 +195,8 @@ public class SendsController : Controller } catch (Exception e) { - _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent)); + _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", + JsonSerializer.Serialize(eventGridEvent)); return; } } @@ -185,6 +208,7 @@ public class SendsController : Controller #region Non-anonymous endpoints + [Authorize(Policies.Application)] [HttpGet("{id}")] public async Task Get(string id) { @@ -193,6 +217,7 @@ public class SendsController : Controller return new SendResponseModel(send); } + [Authorize(Policies.Application)] [HttpGet("")] public async Task> GetAll() { @@ -203,6 +228,55 @@ public class SendsController : Controller return result; } + [Authorize(Policy = Policies.Send)] + // [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */ + [HttpPost("access/")] + public async Task AccessUsingAuth() + { + var guid = User.GetSendId(); + var send = await _sendRepository.GetByIdAsync(guid); + if (send == null) + { + throw new BadRequestException("Could not locate send"); + } + + var sendResponse = new SendAccessResponseModel(send); + if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault()) + { + var creator = await _userService.GetUserByIdAsync(send.UserId.Value); + sendResponse.CreatorIdentifier = creator.Email; + } + + send.AccessCount++; + await _sendRepository.ReplaceAsync(send); + await _pushNotificationService.PushSyncSendUpdateAsync(send); + + return new ObjectResult(sendResponse); + } + + [Authorize(Policy = Policies.Send)] + // [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */ + [HttpPost("access/file/{fileId}")] + public async Task GetSendFileDownloadDataUsingAuth(string fileId) + { + var sendId = User.GetSendId(); + var send = await _sendRepository.GetByIdAsync(sendId); + + if (send == null) + { + throw new BadRequestException("Could not locate send"); + } + + var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId); + + send.AccessCount++; + await _sendRepository.ReplaceAsync(send); + await _pushNotificationService.PushSyncSendUpdateAsync(send); + + return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url }); + } + + [Authorize(Policies.Application)] [HttpPost("")] public async Task Post([FromBody] SendRequestModel model) { @@ -213,6 +287,7 @@ public class SendsController : Controller return new SendResponseModel(send); } + [Authorize(Policies.Application)] [HttpPost("file/v2")] public async Task PostFile([FromBody] SendRequestModel model) { @@ -243,6 +318,7 @@ public class SendsController : Controller }; } + [Authorize(Policies.Application)] [HttpGet("{id}/file/{fileId}")] public async Task RenewFileUpload(string id, string fileId) { @@ -267,6 +343,7 @@ public class SendsController : Controller }; } + [Authorize(Policies.Application)] [HttpPost("{id}/file/{fileId}")] [SelfHosted(SelfHostedOnly = true)] [RequestSizeLimit(Constants.FileSize501mb)] @@ -283,12 +360,14 @@ public class SendsController : Controller { throw new BadRequestException("Could not locate send"); } + await Request.GetFileAsync(async (stream) => { await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); }); } + [Authorize(Policies.Application)] [HttpPut("{id}")] public async Task Put(string id, [FromBody] SendRequestModel model) { @@ -304,6 +383,7 @@ public class SendsController : Controller return new SendResponseModel(send); } + [Authorize(Policies.Application)] [HttpPut("{id}/remove-password")] public async Task PutRemovePassword(string id) { @@ -322,6 +402,28 @@ public class SendsController : Controller return new SendResponseModel(send); } + // Removes ALL authentication (email or password) if any is present + [Authorize(Policies.Application)] + [HttpPut("{id}/remove-auth")] + public async Task PutRemoveAuth(string id) + { + var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found"); + var send = await _sendRepository.GetByIdAsync(new Guid(id)); + if (send == null || send.UserId != userId) + { + throw new NotFoundException(); + } + + // This endpoint exists because PUT preserves existing Password/Emails when not provided. + // This allows clients to update other fields without re-submitting sensitive auth data. + send.Password = null; + send.Emails = null; + send.AuthType = AuthType.None; + await _nonAnonymousSendCommand.SaveSendAsync(send); + return new SendResponseModel(send); + } + + [Authorize(Policies.Application)] [HttpDelete("{id}")] public async Task Delete(string id) { diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs index f3308dbd5a..00dcb6273f 100644 --- a/src/Api/Tools/Models/Request/SendRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendRequestModel.cs @@ -102,9 +102,17 @@ public class SendRequestModel /// Comma-separated list of emails that may access the send using OTP /// authentication. Mutually exclusive with . /// - [StringLength(4000)] + [EncryptedString] + [EncryptedStringLength(4000)] public string Emails { get; set; } + /// + /// Comma-separated list of email **hashes** that may access the send using OTP + /// authentication. Mutually exclusive with . + /// + [StringLength(4000)] + public string EmailHashes { get; set; } + /// /// When , send access is disabled. /// Defaults to . @@ -253,6 +261,7 @@ public class SendRequestModel // normalize encoding var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries); existingSend.Emails = string.Join(",", emails); + existingSend.EmailHashes = EmailHashes; existingSend.Password = null; existingSend.AuthType = Core.Tools.Enums.AuthType.Email; } diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 6a506cc01f..9e107b491d 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -10,7 +10,6 @@ using Bit.Api.Utilities; using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Response; using Bit.Core; -using Bit.Core.AdminConsole.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -43,7 +42,6 @@ public class CiphersController : Controller private readonly ICipherService _cipherService; private readonly IUserService _userService; private readonly IAttachmentStorageService _attachmentStorageService; - private readonly IProviderService _providerService; private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; @@ -52,7 +50,6 @@ public class CiphersController : Controller private readonly ICollectionRepository _collectionRepository; private readonly IArchiveCiphersCommand _archiveCiphersCommand; private readonly IUnarchiveCiphersCommand _unarchiveCiphersCommand; - private readonly IFeatureService _featureService; public CiphersController( ICipherRepository cipherRepository, @@ -60,7 +57,6 @@ public class CiphersController : Controller ICipherService cipherService, IUserService userService, IAttachmentStorageService attachmentStorageService, - IProviderService providerService, ICurrentContext currentContext, ILogger logger, GlobalSettings globalSettings, @@ -68,15 +64,13 @@ public class CiphersController : Controller IApplicationCacheService applicationCacheService, ICollectionRepository collectionRepository, IArchiveCiphersCommand archiveCiphersCommand, - IUnarchiveCiphersCommand unarchiveCiphersCommand, - IFeatureService featureService) + IUnarchiveCiphersCommand unarchiveCiphersCommand) { _cipherRepository = cipherRepository; _collectionCipherRepository = collectionCipherRepository; _cipherService = cipherService; _userService = userService; _attachmentStorageService = attachmentStorageService; - _providerService = providerService; _currentContext = currentContext; _logger = logger; _globalSettings = globalSettings; @@ -85,7 +79,6 @@ public class CiphersController : Controller _collectionRepository = collectionRepository; _archiveCiphersCommand = archiveCiphersCommand; _unarchiveCiphersCommand = unarchiveCiphersCommand; - _featureService = featureService; } [HttpGet("{id}")] @@ -344,8 +337,7 @@ public class CiphersController : Controller throw new NotFoundException(); } - bool excludeDefaultUserCollections = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) && !includeMemberItems; - var allOrganizationCiphers = excludeDefaultUserCollections + var allOrganizationCiphers = !includeMemberItems ? await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId) : @@ -911,7 +903,7 @@ public class CiphersController : Controller [HttpPut("{id}/archive")] [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] - public async Task PutArchive(Guid id) + public async Task PutArchive(Guid id) { var userId = _userService.GetProperUserId(User).Value; @@ -922,12 +914,16 @@ public class CiphersController : Controller 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); + return new CipherResponseModel(archivedCipherOrganizationDetails.First(), + await _userService.GetUserByPrincipalAsync(User), + await _applicationCacheService.GetOrganizationAbilitiesAsync(), + _globalSettings + ); } [HttpPut("archive")] [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] - public async Task> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model) + public async Task> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) { @@ -935,6 +931,7 @@ public class CiphersController : Controller } var userId = _userService.GetProperUserId(User).Value; + var user = await _userService.GetUserByPrincipalAsync(User); var cipherIdsToArchive = new HashSet(model.Ids); @@ -945,9 +942,14 @@ public class CiphersController : Controller 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)); + var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var responses = archivedCiphers.Select(c => new CipherResponseModel(c, + user, + organizationAbilities, + _globalSettings + )); - return new ListResponseModel(responses); + return new ListResponseModel(responses); } [HttpDelete("{id}")] @@ -1109,7 +1111,7 @@ public class CiphersController : Controller [HttpPut("{id}/unarchive")] [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] - public async Task PutUnarchive(Guid id) + public async Task PutUnarchive(Guid id) { var userId = _userService.GetProperUserId(User).Value; @@ -1120,12 +1122,16 @@ public class CiphersController : Controller 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); + return new CipherResponseModel(unarchivedCipherDetails.First(), + await _userService.GetUserByPrincipalAsync(User), + await _applicationCacheService.GetOrganizationAbilitiesAsync(), + _globalSettings + ); } [HttpPut("unarchive")] [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] - public async Task> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model) + public async Task> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) { @@ -1133,6 +1139,8 @@ public class CiphersController : Controller } var userId = _userService.GetProperUserId(User).Value; + var user = await _userService.GetUserByPrincipalAsync(User); + var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); var cipherIdsToUnarchive = new HashSet(model.Ids); @@ -1143,9 +1151,9 @@ public class CiphersController : Controller 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)); + var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherResponseModel(c, user, organizationAbilities, _globalSettings)); - return new ListResponseModel(responses); + return new ListResponseModel(responses); } [HttpPut("{id}/restore")] diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index 6ac8d06ba0..b186e4b601 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -6,6 +6,7 @@ using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; @@ -44,6 +45,7 @@ public class SyncController : Controller private readonly IFeatureService _featureService; private readonly IApplicationCacheService _applicationCacheService; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; private readonly IUserAccountKeysQuery _userAccountKeysQuery; public SyncController( @@ -61,6 +63,7 @@ public class SyncController : Controller IFeatureService featureService, IApplicationCacheService applicationCacheService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IWebAuthnCredentialRepository webAuthnCredentialRepository, IUserAccountKeysQuery userAccountKeysQuery) { _userService = userService; @@ -77,6 +80,7 @@ public class SyncController : Controller _featureService = featureService; _applicationCacheService = applicationCacheService; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + _webAuthnCredentialRepository = webAuthnCredentialRepository; _userAccountKeysQuery = userAccountKeysQuery; } @@ -120,6 +124,9 @@ public class SyncController : Controller var organizationIdsClaimingActiveUser = organizationClaimingActiveUser.Select(o => o.Id); var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var webAuthnCredentials = _featureService.IsEnabled(FeatureFlagKeys.PM2035PasskeyUnlock) + ? await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id) + : []; UserAccountKeysData userAccountKeys = null; // JIT TDE users and some broken/old users may not have a private key. @@ -130,7 +137,7 @@ public class SyncController : Controller var response = new SyncResponseModel(_globalSettings, user, userAccountKeys, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationAbilities, organizationIdsClaimingActiveUser, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, - folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends); + folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends, webAuthnCredentials); return response; } diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 18a1aec559..9a2c279a28 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -80,6 +80,7 @@ public class CipherRequestModel { existingCipher.FolderId = string.IsNullOrWhiteSpace(FolderId) ? null : (Guid?)new Guid(FolderId); existingCipher.Favorite = Favorite; + existingCipher.ArchivedDate = ArchivedDate; ToCipher(existingCipher); return existingCipher; } @@ -127,9 +128,9 @@ public class CipherRequestModel var userIdKey = userId.HasValue ? userId.ToString().ToUpperInvariant() : null; existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; - existingCipher.ArchivedDate = ArchivedDate; existingCipher.Folders = UpdateUserSpecificJsonField(existingCipher.Folders, userIdKey, FolderId); existingCipher.Favorites = UpdateUserSpecificJsonField(existingCipher.Favorites, userIdKey, Favorite); + existingCipher.Archives = UpdateUserSpecificJsonField(existingCipher.Archives, userIdKey, ArchivedDate); var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index dfacc1a551..ac11eb3cd3 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -70,7 +70,6 @@ public class CipherMiniResponseModel : ResponseModel DeletedDate = cipher.DeletedDate; Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None); Key = cipher.Key; - ArchivedDate = cipher.ArchivedDate; } public Guid Id { get; set; } @@ -111,7 +110,6 @@ 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 @@ -127,6 +125,7 @@ public class CipherResponseModel : CipherMiniResponseModel FolderId = cipher.FolderId; Favorite = cipher.Favorite; Edit = cipher.Edit; + ArchivedDate = cipher.ArchivedDate; ViewPassword = cipher.ViewPassword; Permissions = new CipherPermissionsResponseModel(user, cipher, organizationAbilities); } @@ -135,6 +134,7 @@ public class CipherResponseModel : CipherMiniResponseModel public bool Favorite { get; set; } public bool Edit { get; set; } public bool ViewPassword { get; set; } + public DateTime? ArchivedDate { get; set; } public CipherPermissionsResponseModel Permissions { get; set; } } diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index c965320b94..8f90452c6c 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -6,6 +6,9 @@ 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.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Entities; using Bit.Core.KeyManagement.Models.Api.Response; using Bit.Core.KeyManagement.Models.Data; @@ -39,7 +42,8 @@ public class SyncResponseModel() : ResponseModel("sync") IDictionary> collectionCiphersDict, bool excludeDomains, IEnumerable policies, - IEnumerable sends) + IEnumerable sends, + IEnumerable webAuthnCredentials) : this() { Profile = new ProfileResponseModel(user, userAccountKeysData, organizationUserDetails, providerUserDetails, @@ -57,6 +61,16 @@ public class SyncResponseModel() : ResponseModel("sync") Domains = excludeDomains ? null : new DomainsResponseModel(user, false); Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List(); Sends = sends.Select(s => new SendResponseModel(s)); + var webAuthnPrfOptions = webAuthnCredentials + .Where(c => c.GetPrfStatus() == WebAuthnPrfStatus.Enabled) + .Select(c => new WebAuthnPrfDecryptionOption( + c.EncryptedPrivateKey, + c.EncryptedUserKey, + c.CredentialId, + [] // transports as empty array + )) + .ToArray(); + UserDecryption = new UserDecryptionResponseModel { MasterPasswordUnlock = user.HasMasterPassword() @@ -72,7 +86,8 @@ public class SyncResponseModel() : ResponseModel("sync") MasterKeyEncryptedUserKey = user.Key!, Salt = user.Email.ToLowerInvariant() } - : null + : null, + WebAuthnPrfOptions = webAuthnPrfOptions.Length > 0 ? webAuthnPrfOptions : null }; } diff --git a/src/Api/appsettings.Production.json b/src/Api/appsettings.Production.json index d9efbcda12..a6578c08dc 100644 --- a/src/Api/appsettings.Production.json +++ b/src/Api/appsettings.Production.json @@ -23,11 +23,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 69999dc795..27ee9a7ce3 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -3,12 +3,13 @@ bitwarden-Billing + + $(WarningsNotAsErrors);CA1305 false - false false diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs index 312ed3122b..7dfc44069c 100644 --- a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -4,6 +4,7 @@ using Bit.Billing.Services; using Bit.Core; using Bit.Core.Billing.Constants; using Bit.Core.Jobs; +using Bit.Core.Repositories; using Bit.Core.Services; using Quartz; using Stripe; @@ -13,12 +14,23 @@ namespace Bit.Billing.Jobs; public class ReconcileAdditionalStorageJob( IStripeFacade stripeFacade, ILogger logger, - IFeatureService featureService) : BaseJob(logger) + IFeatureService featureService, + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IStripeEventUtilityService stripeEventUtilityService) : BaseJob(logger) { private const string _storageGbMonthlyPriceId = "storage-gb-monthly"; private const string _storageGbAnnuallyPriceId = "storage-gb-annually"; private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually"; private const int _storageGbToRemove = 4; + private const short _includedStorageGb = 5; + + public enum SubscriptionPlanTier + { + Personal, + Organization, + Unknown + } protected override async Task ExecuteJobAsync(IJobExecutionContext context) { @@ -34,6 +46,7 @@ public class ReconcileAdditionalStorageJob( var subscriptionsFound = 0; var subscriptionsUpdated = 0; var subscriptionsWithErrors = 0; + var databaseUpdatesFailed = 0; var failures = new List(); logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode); @@ -51,11 +64,13 @@ public class ReconcileAdditionalStorageJob( { logger.LogWarning( "Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " + - "Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}", + "Stripe updates: {StripeUpdates}, Database updates: {DatabaseFailed} failed, " + + "Errors: {SubscriptionsWithErrors}{Failures}", subscriptionsFound, liveMode ? subscriptionsUpdated : $"(In live mode, would have updated) {subscriptionsUpdated}", + databaseUpdatesFailed, subscriptionsWithErrors, failures.Count > 0 ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" @@ -99,20 +114,68 @@ public class ReconcileAdditionalStorageJob( subscriptionsUpdated++; - if (!liveMode) + // Now, prepare the database update so we can log details out if not in live mode + var (organizationId, userId, _) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata ?? new Dictionary()); + var subscriptionPlanTier = DetermineSubscriptionPlanTier(userId, organizationId); + + if (subscriptionPlanTier == SubscriptionPlanTier.Unknown) { - logger.LogInformation( - "Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}", - subscription.Id, - Environment.NewLine, - JsonSerializer.Serialize(updateOptions)); + logger.LogError( + "Cannot determine subscription plan tier for {SubscriptionId}. Skipping subscription. ", + subscription.Id); + subscriptionsWithErrors++; continue; } + var entityId = + subscriptionPlanTier switch + { + SubscriptionPlanTier.Personal => userId!.Value, + SubscriptionPlanTier.Organization => organizationId!.Value, + _ => throw new ArgumentOutOfRangeException(nameof(subscriptionPlanTier), subscriptionPlanTier, null) + }; + + // Calculate new MaxStorageGb + var currentStorageQuantity = GetCurrentStorageQuantityFromSubscription(subscription, priceId); + var newMaxStorageGb = CalculateNewMaxStorageGb(currentStorageQuantity, updateOptions); + + if (!liveMode) + { + logger.LogInformation( + "Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}" + + "{NewLine2}And would have updated database record tier: {Tier} to new MaxStorageGb: {MaxStorageGb}", + subscription.Id, + Environment.NewLine, + JsonSerializer.Serialize(updateOptions), + Environment.NewLine, + subscriptionPlanTier, + newMaxStorageGb); + continue; + } + + // Live mode enabled - continue with updates to stripe and database try { await stripeFacade.UpdateSubscription(subscription.Id, updateOptions); - logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id); + logger.LogInformation("Successfully updated Stripe subscription: {SubscriptionId}", subscription.Id); + + logger.LogInformation( + "Updating MaxStorageGb in database for subscription {SubscriptionId} ({Type}): New MaxStorageGb: {MaxStorage}", + subscription.Id, + subscriptionPlanTier, + newMaxStorageGb); + + var dbUpdateSuccess = await UpdateDatabaseMaxStorageAsync( + subscriptionPlanTier, + entityId, + newMaxStorageGb, + subscription.Id); + + if (!dbUpdateSuccess) + { + databaseUpdatesFailed++; + failures.Add($"Subscription {subscription.Id}: Database update failed"); + } } catch (Exception ex) { @@ -125,12 +188,14 @@ public class ReconcileAdditionalStorageJob( } logger.LogInformation( - "ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " + - "Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}", + "ReconcileAdditionalStorageJob FINISHED. Subscriptions found: {SubscriptionsFound}, " + + "Subscriptions updated: {SubscriptionsUpdated}, Database failures: {DatabaseFailed}, " + + "Total Subscriptions With Errors: {SubscriptionsWithErrors}{Failures}", subscriptionsFound, liveMode ? subscriptionsUpdated : $"(In live mode, would have updated) {subscriptionsUpdated}", + databaseUpdatesFailed, subscriptionsWithErrors, failures.Count > 0 ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" @@ -182,6 +247,117 @@ public class ReconcileAdditionalStorageJob( return hasUpdates ? updateOptions : null; } + public SubscriptionPlanTier DetermineSubscriptionPlanTier( + Guid? userId, + Guid? organizationId) + { + return userId.HasValue + ? SubscriptionPlanTier.Personal + : organizationId.HasValue + ? SubscriptionPlanTier.Organization + : SubscriptionPlanTier.Unknown; + } + + public long GetCurrentStorageQuantityFromSubscription( + Subscription subscription, + string storagePriceId) + { + return subscription.Items?.Data?.FirstOrDefault(item => item?.Price?.Id == storagePriceId)?.Quantity ?? 0; + } + + public short CalculateNewMaxStorageGb( + long currentQuantity, + SubscriptionUpdateOptions? updateOptions) + { + if (updateOptions?.Items == null) + { + return (short)(_includedStorageGb + currentQuantity); + } + + // If the update marks item as deleted, new quantity is whatever the base storage gb + if (updateOptions.Items.Any(i => i.Deleted == true)) + { + return _includedStorageGb; + } + + // If the update has a new quantity, use it to calculate the new max + var updatedItem = updateOptions.Items.FirstOrDefault(i => i.Quantity.HasValue); + if (updatedItem?.Quantity != null) + { + return (short)(_includedStorageGb + updatedItem.Quantity.Value); + } + + // Otherwise, no change + return (short)(_includedStorageGb + currentQuantity); + } + + public async Task UpdateDatabaseMaxStorageAsync( + SubscriptionPlanTier subscriptionPlanTier, + Guid entityId, + short newMaxStorageGb, + string subscriptionId) + { + try + { + switch (subscriptionPlanTier) + { + case SubscriptionPlanTier.Personal: + { + var user = await userRepository.GetByIdAsync(entityId); + if (user == null) + { + logger.LogError( + "User not found for subscription {SubscriptionId}. Database not updated.", + subscriptionId); + return false; + } + + user.MaxStorageGb = newMaxStorageGb; + await userRepository.ReplaceAsync(user); + + logger.LogInformation( + "Successfully updated User {UserId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}", + user.Id, + newMaxStorageGb, + subscriptionId); + return true; + } + case SubscriptionPlanTier.Organization: + { + var organization = await organizationRepository.GetByIdAsync(entityId); + if (organization == null) + { + logger.LogError( + "Organization not found for subscription {SubscriptionId}. Database not updated.", + subscriptionId); + return false; + } + + organization.MaxStorageGb = newMaxStorageGb; + await organizationRepository.ReplaceAsync(organization); + + logger.LogInformation( + "Successfully updated Organization {OrganizationId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}", + organization.Id, + newMaxStorageGb, + subscriptionId); + return true; + } + case SubscriptionPlanTier.Unknown: + default: + return false; + } + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to update database MaxStorageGb for subscription {SubscriptionId} (Plan Tier: {SubscriptionType})", + subscriptionId, + subscriptionPlanTier); + return false; + } + } + public static ITrigger GetTrigger() { return TriggerBuilder.Create() diff --git a/src/Billing/Models/PayPalIPNTransactionModel.cs b/src/Billing/Models/PayPalIPNTransactionModel.cs index 34db5fdd04..240fa5ab76 100644 --- a/src/Billing/Models/PayPalIPNTransactionModel.cs +++ b/src/Billing/Models/PayPalIPNTransactionModel.cs @@ -43,7 +43,7 @@ public class PayPalIPNTransactionModel var merchantGross = Extract(data, "mc_gross"); if (!string.IsNullOrEmpty(merchantGross)) { - MerchantGross = decimal.Parse(merchantGross); + MerchantGross = decimal.Parse(merchantGross, CultureInfo.InvariantCulture); } MerchantCurrency = Extract(data, "mc_currency"); diff --git a/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs b/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs index 101b0e26b9..0db498844e 100644 --- a/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs +++ b/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs @@ -1,12 +1,13 @@ using Bit.Core.Billing.Constants; +using Bit.Core.Services; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; public class InvoiceCreatedHandler( + IBraintreeService braintreeService, ILogger logger, IStripeEventService stripeEventService, - IStripeEventUtilityService stripeEventUtilityService, IProviderEventService providerEventService) : IInvoiceCreatedHandler { @@ -29,9 +30,9 @@ public class InvoiceCreatedHandler( { try { - var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]); + var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer", "parent.subscription_details.subscription"]); - var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false; + var usingPayPal = invoice.Customer.Metadata.ContainsKey("btCustomerId"); if (usingPayPal && invoice is { @@ -39,13 +40,12 @@ public class InvoiceCreatedHandler( Status: not StripeConstants.InvoiceStatus.Paid, CollectionMethod: "charge_automatically", BillingReason: - "subscription_create" or "subscription_cycle" or "automatic_pending_invoice_item_invoice", - Parent.SubscriptionDetails: not null + Parent.SubscriptionDetails.Subscription: not null }) { - await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice); + await braintreeService.PayInvoice(invoice.Parent.SubscriptionDetails.Subscription, invoice); } } catch (Exception exception) diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs index 03ca8eeb10..03865b48fe 100644 --- a/src/Billing/Services/Implementations/StripeEventService.cs +++ b/src/Billing/Services/Implementations/StripeEventService.cs @@ -9,6 +9,7 @@ namespace Bit.Billing.Services.Implementations; public class StripeEventService( GlobalSettings globalSettings, + ILogger logger, IOrganizationRepository organizationRepository, IProviderRepository providerRepository, ISetupIntentCache setupIntentCache, @@ -148,26 +149,36 @@ public class StripeEventService( { var setupIntent = await GetSetupIntent(localStripeEvent); + logger.LogInformation("Extracted Setup Intent ({SetupIntentId}) from Stripe 'setup_intent.succeeded' event", setupIntent.Id); + var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id); + + logger.LogInformation("Retrieved subscriber ID ({SubscriberId}) from cache for Setup Intent ({SetupIntentId})", subscriberId, setupIntent.Id); + if (subscriberId == null) { + logger.LogError("Cached subscriber ID for Setup Intent ({SetupIntentId}) is null", setupIntent.Id); return null; } var organization = await organizationRepository.GetByIdAsync(subscriberId.Value); + logger.LogInformation("Retrieved organization ({OrganizationId}) via subscriber ID for Setup Intent ({SetupIntentId})", organization?.Id, setupIntent.Id); if (organization is { GatewayCustomerId: not null }) { var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId); + logger.LogInformation("Retrieved customer ({CustomerId}) via organization ID for Setup Intent ({SetupIntentId})", organization.Id, setupIntent.Id); return organizationCustomer.Metadata; } var provider = await providerRepository.GetByIdAsync(subscriberId.Value); + logger.LogInformation("Retrieved provider ({ProviderId}) via subscriber ID for Setup Intent ({SetupIntentId})", provider?.Id, setupIntent.Id); if (provider is not { GatewayCustomerId: not null }) { return null; } var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId); + logger.LogInformation("Retrieved customer ({CustomerId}) via provider ID for Setup Intent ({SetupIntentId})", provider.Id, setupIntent.Id); return providerCustomer.Metadata; } } diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index c10368d8c0..9e20bd3191 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -275,17 +275,24 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler .PreviousAttributes .ToObject() as Subscription; + // Get all plan IDs that include Secrets Manager support to check if the organization has secret manager in the + // previous and/or current subscriptions. + var planIdsOfPlansWithSecretManager = (await _pricingClient.ListPlans()) + .Where(orgPlan => orgPlan.SupportsSecretsManager && orgPlan.SecretsManager.StripeSeatPlanId != null) + .Select(orgPlan => orgPlan.SecretsManager.StripeSeatPlanId) + .ToHashSet(); + // This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager. // If there are changes to any subscription item, Stripe sends every item in the subscription, both // changed and unchanged. var previousSubscriptionHasSecretsManager = previousSubscription?.Items is not null && previousSubscription.Items.Any( - previousSubscriptionItem => previousSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId); + previousSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(previousSubscriptionItem.Plan.Id)); var currentSubscriptionHasSecretsManager = subscription.Items.Any( - currentSubscriptionItem => currentSubscriptionItem.Plan.Id == plan.SecretsManager.StripeSeatPlanId); + currentSubscriptionItem => planIdsOfPlansWithSecretManager.Contains(currentSubscriptionItem.Plan.Id)); if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager) { diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 004828dc48..ae2a76a7ce 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -627,7 +627,7 @@ public class UpcomingInvoiceHandler( { BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")), DiscountAmount = $"{coupon.PercentOff}%", - DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US")) + DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US")) } }; diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 30f4f5f562..f5f98bfd53 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -48,6 +48,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // PayPal IPN Client services.AddHttpClient(); diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index 338b150de6..266779d574 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -134,6 +134,11 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable /// public bool UseAutomaticUserConfirmation { get; set; } + /// + /// If set to true, disables Secrets Manager ads for users in the organization + /// + public bool UseDisableSmAdsForUsers { get; set; } + /// /// If set to true, the organization has phishing protection enabled. /// @@ -338,6 +343,7 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable UseRiskInsights = license.UseRiskInsights; UseOrganizationDomains = license.UseOrganizationDomains; UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies; + UseDisableSmAdsForUsers = license.UseDisableSmAdsForUsers; UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation; UsePhishingBlocker = license.UsePhishingBlocker; } diff --git a/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs index 0368678641..ed1d9e5f13 100644 --- a/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs @@ -53,5 +53,7 @@ public interface IProfileOrganizationDetails bool UseAdminSponsoredFamilies { get; set; } bool UseOrganizationDomains { get; set; } bool UseAutomaticUserConfirmation { get; set; } + bool UseDisableSMAdsForUsers { get; set; } + bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index 00b9280337..2a7c2cb628 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -65,5 +65,6 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails public bool UseAdminSponsoredFamilies { get; set; } public bool? IsAdminInitiated { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UseDisableSMAdsForUsers { get; set; } public bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs b/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs index b66244ba5f..228d7a26f1 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs @@ -1,11 +1,21 @@ -using System.Text.Json.Serialization; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies; public class MasterPasswordPolicyData : IPolicyDataModel { + /// + /// Minimum password complexity score (0-4). Null indicates no complexity requirement. + /// [JsonPropertyName("minComplexity")] + [Range(0, 4)] public int? MinComplexity { get; set; } + + /// + /// Minimum password length (12-128). Null indicates no minimum length requirement. + /// [JsonPropertyName("minLength")] + [Range(12, 128)] public int? MinLength { get; set; } [JsonPropertyName("requireLower")] public bool? RequireLower { get; set; } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/Policies/PolicyStatus.cs b/src/Core/AdminConsole/Models/Data/Organizations/Policies/PolicyStatus.cs new file mode 100644 index 0000000000..68c754f6ba --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Organizations/Policies/PolicyStatus.cs @@ -0,0 +1,26 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies; + +public class PolicyStatus +{ + public PolicyStatus(Guid organizationId, PolicyType policyType, Policy? policy = null) + { + OrganizationId = policy?.OrganizationId ?? organizationId; + Data = policy?.Data; + Type = policy?.Type ?? policyType; + Enabled = policy?.Enabled ?? false; + } + + public Guid OrganizationId { get; set; } + public PolicyType Type { get; set; } + public bool Enabled { get; set; } + public string? Data { get; set; } + + public T GetDataModel() where T : IPolicyDataModel, new() + { + return CoreHelpers.LoadClassFromJsonData(Data); + } +} diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index 484320c271..5ec9dc255a 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -128,6 +128,7 @@ public class SelfHostedOrganizationDetails : Organization UseApi = UseApi, UseResetPassword = UseResetPassword, UseSecretsManager = UseSecretsManager, + UsePasswordManager = UsePasswordManager, SelfHost = SelfHost, UsersGetPremium = UsersGetPremium, UseCustomPermissions = UseCustomPermissions, @@ -154,7 +155,10 @@ public class SelfHostedOrganizationDetails : Organization Status = Status, UseRiskInsights = UseRiskInsights, UseAdminSponsoredFamilies = UseAdminSponsoredFamilies, + UseDisableSmAdsForUsers = UseDisableSmAdsForUsers, UsePhishingBlocker = UsePhishingBlocker, + UseOrganizationDomains = UseOrganizationDomains, + UseAutomaticUserConfirmation = UseAutomaticUserConfirmation, }; } } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index dcec028dcc..69b17a9a80 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -56,5 +56,6 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails public string? SsoExternalId { get; set; } public string? Permissions { get; set; } public string? ResetPasswordKey { get; set; } + public bool UseDisableSMAdsForUsers { get; set; } public bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationBaseView.cs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationBaseView.cs new file mode 100644 index 0000000000..f888496fe8 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationBaseView.cs @@ -0,0 +1,12 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; + +public abstract class OrganizationConfirmationBaseView : BaseMailView +{ + public required string OrganizationName { get; set; } + public required string TitleFirst { get; set; } + public required string TitleSecondBold { get; set; } + public required string TitleThird { get; set; } + public required string WebVaultUrl { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.cs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.cs new file mode 100644 index 0000000000..6a8a0e18fa --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.cs @@ -0,0 +1,12 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; + +public class OrganizationConfirmationEnterpriseTeamsView : OrganizationConfirmationBaseView +{ +} + +public class OrganizationConfirmationEnterpriseTeams : BaseMail +{ + public override required string Subject { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs new file mode 100644 index 0000000000..3c8f498403 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.html.hbs @@ -0,0 +1,814 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ You can now share passwords with members of {{OrganizationName}}! +

+ +
+ + + + + + + +
+ + Log in + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
As a member of {{OrganizationName}}:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Organization Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
Your account is owned by {{OrganizationName}} and is subject to their security and management policies.
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Group Users Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + +
+ +
You can easily access and share passwords with your team.
+ +
+ + + +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 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/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.text.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.text.hbs similarity index 100% rename from src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.text.hbs rename to src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationEnterpriseTeamsView.text.hbs diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.cs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.cs new file mode 100644 index 0000000000..9228ec2208 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.cs @@ -0,0 +1,12 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; + +public class OrganizationConfirmationFamilyFreeView : OrganizationConfirmationBaseView +{ +} + +public class OrganizationConfirmationFamilyFree : BaseMail +{ + public override required string Subject { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs new file mode 100644 index 0000000000..c0f838e0c7 --- /dev/null +++ b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.html.hbs @@ -0,0 +1,983 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ You can now share passwords with members of {{OrganizationName}}! +

+ +
+ + + + + + + +
+ + Log in + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
As a member of {{OrganizationName}}:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Collections Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
You can access passwords {{OrganizationName}} has shared with you.
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Group Users Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + +
+ +
You can easily share passwords with friends, family, or coworkers.
+ +
+ + + +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ +
Download Bitwarden on all devices
+ +
+ +
Already using the browser extension? + Download the Bitwarden mobile app from the + App Store + or Google Play + to quickly save logins and autofill forms on the go.
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + + Download on the App Store + + + +
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + + Get it on Google Play + + + +
+ +
+ +
+ + +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 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/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.text.hbs b/src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.text.hbs similarity index 100% rename from src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.text.hbs rename to src/Core/AdminConsole/Models/Mail/Mailer/OrganizationConfirmation/OrganizationConfirmationFamilyFreeView.text.hbs diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs index 5783301a0b..bd30112945 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs @@ -1,5 +1,5 @@ using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Identity; namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IUserRepository userRepository, IMailService mailService, IEventService eventService, @@ -30,9 +30,8 @@ public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepo } // Enterprise policy must be enabled - var resetPasswordPolicy = - await policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); - if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled) + var resetPasswordPolicy = await policyQuery.RunAsync(orgId, PolicyType.ResetPassword); + if (!resetPasswordPolicy.Enabled) { throw new BadRequestException("Organization does not have the password reset policy enabled."); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs b/src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs new file mode 100644 index 0000000000..116992146f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Collections/CollectionUtils.cs @@ -0,0 +1,53 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections; + +public static class CollectionUtils +{ + /// + /// Arranges Collection and CollectionUser objects to create default user collections. + /// + /// The organization ID. + /// The IDs for organization users who need default collections. + /// The encrypted string to use as the default collection name. + /// A tuple containing the collections and collection users. + public static (ICollection collections, ICollection collectionUsers) + BuildDefaultUserCollections(Guid organizationId, IEnumerable organizationUserIds, + string defaultCollectionName) + { + var now = DateTime.UtcNow; + + var collectionUsers = new List(); + var collections = new List(); + + foreach (var orgUserId in organizationUserIds) + { + var collectionId = CoreHelpers.GenerateComb(); + + collections.Add(new Collection + { + Id = collectionId, + OrganizationId = organizationId, + Name = defaultCollectionName, + CreationDate = now, + RevisionDate = now, + Type = CollectionType.DefaultUserCollection, + DefaultUserCollectionEmail = null + + }); + + collectionUsers.Add(new CollectionUser + { + CollectionId = collectionId, + OrganizationUserId = orgUserId, + ReadOnly = false, + HidePasswords = false, + Manage = true, + }); + } + + return (collections, collectionUsers); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs index 7c8389c103..40249fa2be 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs @@ -29,6 +29,7 @@ public class OrganizationAbility UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation; + UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers; UsePhishingBlocker = organization.UsePhishingBlocker; } @@ -52,5 +53,6 @@ public class OrganizationAbility public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UseDisableSmAdsForUsers { get; set; } public bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs index 67b5f0da80..0292381857 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs @@ -1,10 +1,10 @@ -using Bit.Core.AdminConsole.Models.Data.OrganizationUsers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -25,6 +25,8 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi IPushNotificationService pushNotificationService, IPolicyRequirementQuery policyRequirementQuery, ICollectionRepository collectionRepository, + IFeatureService featureService, + ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand, TimeProvider timeProvider, ILogger logger) : IAutomaticallyConfirmOrganizationUserCommand { @@ -79,19 +81,10 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi return; } - await collectionRepository.CreateAsync( - new Collection - { - OrganizationId = request.Organization!.Id, - Name = request.DefaultUserCollectionName, - Type = CollectionType.DefaultUserCollection - }, - groups: null, - [new CollectionAccessSelection - { - Id = request.OrganizationUser!.Id, - Manage = true - }]); + await collectionRepository.CreateDefaultCollectionsAsync( + request.Organization!.Id, + [request.OrganizationUser!.Id], + request.DefaultUserCollectionName); } catch (Exception ex) { @@ -143,9 +136,7 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi { var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value); - await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name, - user!.Email, - request.OrganizationUser.AccessSecretsManager); + await SendOrganizationConfirmedEmailAsync(request.Organization!, user!.Email, request.OrganizationUser.AccessSecretsManager); } catch (Exception ex) { @@ -183,4 +174,23 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi Organization = await organizationRepository.GetByIdAsync(request.OrganizationId) }; } + + /// + /// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service, + /// depending on the feature flag. + /// + /// The organization the user was confirmed to. + /// The email address of the confirmed user. + /// Whether the user has access to Secrets Manager. + internal async Task SendOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager) + { + if (featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)) + { + await sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager); + } + else + { + await mailService.SendOrganizationConfirmedEmailAsync(organization.Name, userEmail, accessSecretsManager); + } + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs index 3375120516..f067f529ea 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUsersValidator.cs @@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -20,7 +19,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator( IPolicyRequirementQuery policyRequirementQuery, IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, IUserService userService, - IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator + IPolicyQuery policyQuery) : IAutomaticallyConfirmOrganizationUsersValidator { public async Task> ValidateAsync( AutomaticallyConfirmOrganizationUserValidationRequest request) @@ -74,7 +73,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator( } private async Task OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) => - await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation) is { Enabled: true } + (await policyQuery.RunAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation)).Enabled && request.Organization is { UseAutomaticUserConfirmation: true }; private async Task OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index b6b49e93e9..02f3346ba6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -1,8 +1,10 @@ // 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.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -12,7 +14,6 @@ using Bit.Core.Billing.Enums; 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; @@ -35,7 +36,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand private readonly IFeatureService _featureService; private readonly ICollectionRepository _collectionRepository; private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator; - + private readonly ISendOrganizationConfirmationCommand _sendOrganizationConfirmationCommand; public ConfirmOrganizationUserCommand( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -50,7 +51,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, ICollectionRepository collectionRepository, - IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -66,8 +67,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand _featureService = featureService; _collectionRepository = collectionRepository; _automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator; + _sendOrganizationConfirmationCommand = sendOrganizationConfirmationCommand; } - public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, string defaultUserCollectionName = null) { @@ -170,7 +171,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand orgUser.Email = null; await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager); + await SendOrganizationConfirmedEmailAsync(organization, user.Email, orgUser.AccessSecretsManager); succeededUsers.Add(orgUser); result.Add(Tuple.Create(orgUser, "")); } @@ -280,11 +281,6 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand /// The encrypted default user collection name. private async Task CreateDefaultCollectionAsync(OrganizationUser organizationUser, string defaultUserCollectionName) { - if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) - { - return; - } - // Skip if no collection name provided (backwards compatibility) if (string.IsNullOrWhiteSpace(defaultUserCollectionName)) { @@ -297,21 +293,10 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - var defaultCollection = new Collection - { - OrganizationId = organizationUser.OrganizationId, - Name = defaultUserCollectionName, - Type = CollectionType.DefaultUserCollection - }; - var collectionUser = new CollectionAccessSelection - { - Id = organizationUser.Id, - ReadOnly = false, - HidePasswords = false, - Manage = true - }; - - await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]); + await _collectionRepository.CreateDefaultCollectionsAsync( + organizationUser.OrganizationId, + [organizationUser.Id], + defaultUserCollectionName); } /// @@ -323,11 +308,6 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand 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)) { @@ -347,6 +327,25 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); + await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); + } + + /// + /// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service, + /// depending on the feature flag. + /// + /// The organization the user was confirmed to. + /// The email address of the confirmed user. + /// Whether the user has access to Secrets Manager. + internal async Task SendOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager) + { + if (_featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)) + { + await _sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager); + } + else + { + await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), userEmail, accessSecretsManager); + } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs index cd5066d11b..61f428414f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -4,7 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Models.Business; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; @@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse public class SendOrganizationInvitesCommand( IUserRepository userRepository, ISsoConfigRepository ssoConfigurationRepository, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IDataProtectorTokenFactory dataProtectorTokenFactory, IMailService mailService) : ISendOrganizationInvitesCommand @@ -58,7 +58,7 @@ public class SendOrganizationInvitesCommand( // need to check the policy if the org has SSO enabled. var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled && organization.UsePolicies && - (await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true; + (await policyQuery.RunAsync(organization.Id, PolicyType.RequireSso)).Enabled; // Generate the list of org users and expiring tokens // create helper function to create expiring tokens diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/ISendOrganizationConfirmationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/ISendOrganizationConfirmationCommand.cs new file mode 100644 index 0000000000..ae4d7acda5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/ISendOrganizationConfirmationCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; + +public interface ISendOrganizationConfirmationCommand +{ + /// + /// Sends an organization confirmation email to the specified user. + /// + /// The organization to send the confirmation email for. + /// The email address of the user to send the confirmation to. + /// Whether the user has access to Secrets Manager. + Task SendConfirmationAsync(Organization organization, string userEmail, bool accessSecretsManager); + + /// + /// Sends organization confirmation emails to multiple users. + /// + /// The organization to send the confirmation emails for. + /// The email addresses of the users to send confirmations to. + /// Whether the users have access to Secrets Manager. + Task SendConfirmationsAsync(Organization organization, IEnumerable userEmails, bool accessSecretsManager); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommand.cs new file mode 100644 index 0000000000..952478ce36 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommand.cs @@ -0,0 +1,110 @@ +using System.Net; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; +using Bit.Core.Billing.Enums; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; + +public class SendOrganizationConfirmationCommand(IMailer mailer, GlobalSettings globalSettings) : ISendOrganizationConfirmationCommand +{ + private const string _titleFirst = "You're confirmed as a member of "; + private const string _titleThird = "!"; + + private static string GetConfirmationSubject(string organizationName) => + $"You can now access items from {organizationName}"; + private string GetWebVaultUrl(bool accessSecretsManager) => accessSecretsManager + ? globalSettings.BaseServiceUri.VaultWithHashAndSecretManagerProduct + : globalSettings.BaseServiceUri.VaultWithHash; + + public async Task SendConfirmationAsync(Organization organization, string userEmail, bool accessSecretsManager = false) + { + await SendConfirmationsAsync(organization, [userEmail], accessSecretsManager); + } + + public async Task SendConfirmationsAsync(Organization organization, IEnumerable userEmails, bool accessSecretsManager = false) + { + var userEmailsList = userEmails.ToList(); + + if (userEmailsList.Count == 0) + { + return; + } + + var organizationName = WebUtility.HtmlDecode(organization.Name); + + if (IsEnterpriseOrTeamsPlan(organization.PlanType)) + { + await SendEnterpriseTeamsEmailsAsync(userEmailsList, organizationName, accessSecretsManager); + return; + } + + await SendFamilyFreeConfirmEmailsAsync(userEmailsList, organizationName, accessSecretsManager); + } + + private async Task SendEnterpriseTeamsEmailsAsync(List userEmailsList, string organizationName, bool accessSecretsManager) + { + var mail = new OrganizationConfirmationEnterpriseTeams + { + ToEmails = userEmailsList, + Subject = GetConfirmationSubject(organizationName), + View = new OrganizationConfirmationEnterpriseTeamsView + { + OrganizationName = organizationName, + TitleFirst = _titleFirst, + TitleSecondBold = organizationName, + TitleThird = _titleThird, + WebVaultUrl = GetWebVaultUrl(accessSecretsManager) + } + }; + + await mailer.SendEmail(mail); + } + + private async Task SendFamilyFreeConfirmEmailsAsync(List userEmailsList, string organizationName, bool accessSecretsManager) + { + var mail = new OrganizationConfirmationFamilyFree + { + ToEmails = userEmailsList, + Subject = GetConfirmationSubject(organizationName), + View = new OrganizationConfirmationFamilyFreeView + { + OrganizationName = organizationName, + TitleFirst = _titleFirst, + TitleSecondBold = organizationName, + TitleThird = _titleThird, + WebVaultUrl = GetWebVaultUrl(accessSecretsManager) + } + }; + + await mailer.SendEmail(mail); + } + + + private static bool IsEnterpriseOrTeamsPlan(PlanType planType) + { + return planType switch + { + PlanType.TeamsMonthly2019 or + PlanType.TeamsAnnually2019 or + PlanType.TeamsMonthly2020 or + PlanType.TeamsAnnually2020 or + PlanType.TeamsMonthly2023 or + PlanType.TeamsAnnually2023 or + PlanType.TeamsStarter2023 or + PlanType.TeamsMonthly or + PlanType.TeamsAnnually or + PlanType.TeamsStarter or + PlanType.EnterpriseMonthly2019 or + PlanType.EnterpriseAnnually2019 or + PlanType.EnterpriseMonthly2020 or + PlanType.EnterpriseAnnually2020 or + PlanType.EnterpriseMonthly2023 or + PlanType.EnterpriseAnnually2023 or + PlanType.EnterpriseMonthly or + PlanType.EnterpriseAnnually => true, + _ => false + }; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/IRestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/IRestoreOrganizationUserCommand.cs index e5e5bfb482..82ea0a1c11 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/IRestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/IRestoreOrganizationUserCommand.cs @@ -20,7 +20,7 @@ public interface IRestoreOrganizationUserCommand /// /// Revoked user to be restored. /// UserId of the user performing the action. - Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId); + Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string? defaultCollectionName); /// /// Validates that the requesting user can perform the action. There is also a check done to ensure the organization @@ -50,5 +50,5 @@ public interface IRestoreOrganizationUserCommand /// Passed in from caller to avoid circular dependency /// List of organization user Ids and strings. A successful restoration will have an empty string. /// If an error occurs, the error message will be provided. - Task>> RestoreUsersAsync(Guid organizationId, IEnumerable organizationUserIds, Guid? restoringUserId, IUserService userService); + Task>> RestoreUsersAsync(Guid organizationId, IEnumerable organizationUserIds, Guid? restoringUserId, IUserService userService, string? defaultCollectionName); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index ec42c8b402..a764410e51 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -31,9 +31,10 @@ public class RestoreOrganizationUserCommand( IOrganizationService organizationService, IFeatureService featureService, IPolicyRequirementQuery policyRequirementQuery, + ICollectionRepository collectionRepository, IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) : IRestoreOrganizationUserCommand { - public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId) + public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName) { if (restoringUserId.HasValue && organizationUser.UserId == restoringUserId.Value) { @@ -46,7 +47,7 @@ public class RestoreOrganizationUserCommand( throw new BadRequestException("Only owners can restore other owners."); } - await RepositoryRestoreUserAsync(organizationUser); + await RepositoryRestoreUserAsync(organizationUser, defaultCollectionName); await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); if (organizationUser.UserId.HasValue) @@ -57,7 +58,7 @@ public class RestoreOrganizationUserCommand( public async Task RestoreUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser) { - await RepositoryRestoreUserAsync(organizationUser); + await RepositoryRestoreUserAsync(organizationUser, null); // users stored by a system user will not get a default collection at this point. await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored, systemUser); @@ -67,7 +68,7 @@ public class RestoreOrganizationUserCommand( } } - private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser) + private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser, string defaultCollectionName) { if (organizationUser.Status != OrganizationUserStatusType.Revoked) { @@ -93,7 +94,7 @@ public class RestoreOrganizationUserCommand( .twoFactorIsEnabled; } - if (organization.PlanType == PlanType.Free) + if (organization.PlanType == PlanType.Free && organizationUser.UserId.HasValue) { await CheckUserForOtherFreeOrganizationOwnershipAsync(organizationUser); } @@ -104,7 +105,17 @@ public class RestoreOrganizationUserCommand( await organizationUserRepository.RestoreAsync(organizationUser.Id, status); - organizationUser.Status = status; + if (organizationUser.UserId.HasValue + && (await policyRequirementQuery.GetAsync(organizationUser.UserId + .Value)).State == OrganizationDataOwnershipState.Enabled + && status == OrganizationUserStatusType.Confirmed + && featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + && !string.IsNullOrWhiteSpace(defaultCollectionName)) + { + await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.OrganizationId, + [organizationUser.Id], + defaultCollectionName); + } } private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser) @@ -156,7 +167,8 @@ public class RestoreOrganizationUserCommand( } public async Task>> RestoreUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? restoringUserId, IUserService userService) + IEnumerable organizationUserIds, Guid? restoringUserId, IUserService userService, + string defaultCollectionName) { var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds); var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) @@ -187,6 +199,9 @@ public class RestoreOrganizationUserCommand( var orgUsersAndOrgs = await GetRelatedOrganizationUsersAndOrganizationsAsync(filteredUsers); var result = new List>(); + var organizationUsersDataOwnershipEnabled = (await policyRequirementQuery + .GetManyByOrganizationIdAsync(organizationId)) + .ToList(); foreach (var organizationUser in filteredUsers) { @@ -223,13 +238,24 @@ public class RestoreOrganizationUserCommand( var status = OrganizationService.GetPriorActiveOrganizationUserStatusType(organizationUser); await organizationUserRepository.RestoreAsync(organizationUser.Id, status); - organizationUser.Status = status; - await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + if (organizationUser.UserId.HasValue) { + if (organizationUsersDataOwnershipEnabled.Contains(organizationUser.Id) + && status == OrganizationUserStatusType.Confirmed + && !string.IsNullOrWhiteSpace(defaultCollectionName) + && featureService.IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore)) + { + await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.OrganizationId, + [organizationUser.Id], + defaultCollectionName); + } + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); } + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + result.Add(Tuple.Create(organizationUser, "")); } catch (BadRequestException e) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs index 7b5541c3ce..313c01af7c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs @@ -7,6 +7,4 @@ 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/RevokeUser/v1/RevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs index 7aa67f0813..750ebf2518 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs @@ -68,68 +68,4 @@ public class RevokeOrganizationUserCommand( 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/SelfRevokeUser/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/Errors.cs new file mode 100644 index 0000000000..8c19544aa9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/Errors.cs @@ -0,0 +1,7 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; + +public record OrganizationUserNotFound() : NotFoundError("Organization user not found."); +public record NotEligibleForSelfRevoke() : BadRequestError("User is not eligible for self-revocation. The organization data ownership policy must be enabled and the user must be a confirmed member."); +public record LastOwnerCannotSelfRevoke() : BadRequestError("The last owner cannot revoke themselves."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/ISelfRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/ISelfRevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..3153465a38 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/ISelfRevokeOrganizationUserCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; + +/// +/// Allows users to revoke themselves from an organization when declining to migrate personal items +/// under the OrganizationDataOwnership policy. +/// +public interface ISelfRevokeOrganizationUserCommand +{ + /// + /// Revokes a user from an organization. + /// + /// The organization ID. + /// The user ID to revoke. + /// A indicating success or containing an error. + /// + /// Validates the OrganizationDataOwnership policy is enabled and applies to the user (currently Owners/Admins are exempt), + /// the user is a confirmed member, and prevents the last owner from revoking themselves. + /// + Task SelfRevokeUserAsync(Guid organizationId, Guid userId); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..afc0236af4 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommand.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; + +public class SelfRevokeOrganizationUserCommand( + IOrganizationUserRepository organizationUserRepository, + IPolicyRequirementQuery policyRequirementQuery, + IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, + IEventService eventService, + IPushNotificationService pushNotificationService) + : ISelfRevokeOrganizationUserCommand +{ + public async Task SelfRevokeUserAsync(Guid organizationId, Guid userId) + { + var organizationUser = await organizationUserRepository.GetByOrganizationAsync(organizationId, userId); + if (organizationUser == null) + { + return new OrganizationUserNotFound(); + } + + var policyRequirement = await policyRequirementQuery.GetAsync(userId); + + if (!policyRequirement.EligibleForSelfRevoke(organizationId)) + { + return new NotEligibleForSelfRevoke(); + } + + // Prevent the last owner from revoking themselves, which would brick the organization + if (organizationUser.Type == OrganizationUserType.Owner) + { + var hasOtherOwner = await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync( + organizationId, + [organizationUser.Id], + includeProvider: true); + + if (!hasOtherOwner) + { + return new LastOwnerCannotSelfRevoke(); + } + } + + await organizationUserRepository.RevokeAsync(organizationUser.Id); + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_SelfRevoked); + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId!.Value); + + return new None(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyQuery.cs new file mode 100644 index 0000000000..02eeeaa847 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyQuery.cs @@ -0,0 +1,17 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; + +public interface IPolicyQuery +{ + /// + /// Retrieves a summary view of an organization's usage of a policy specified by the . + /// + /// + /// This query is the entrypoint for consumers interested in understanding how a particular + /// has been applied to an organization; the resultant is not indicative of explicit + /// policy configuration. + /// + Task RunAsync(Guid organizationId, PolicyType policyType); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyQuery.cs new file mode 100644 index 0000000000..0ee6f9ab06 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyQuery.cs @@ -0,0 +1,14 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; + +public class PolicyQuery(IPolicyRepository policyRepository) : IPolicyQuery +{ + public async Task RunAsync(Guid organizationId, PolicyType policyType) + { + var dbPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, policyType); + return new PolicyStatus(organizationId, policyType, dbPolicy); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs index 3430f33a77..9b6cf86257 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs @@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements /// Collection of policy details that apply to this user id public class AutomaticUserConfirmationPolicyRequirement(IEnumerable policyDetails) : IPolicyRequirement { - public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any(); + public bool CannotHaveEmergencyAccess() => policyDetails.Any(); public bool CannotJoinProvider() => policyDetails.Any(); diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs index c9653053ea..d30ba5c39f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs @@ -83,6 +83,24 @@ public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement return _policyDetails.Any(p => p.OrganizationId == organizationId && p.OrganizationUserStatus == OrganizationUserStatusType.Confirmed); } + + /// + /// Determines if a user is eligible for self-revocation under the Organization Data Ownership policy. + /// A user is eligible if they are a confirmed member of the organization and the policy is enabled. + /// This also handles exempt roles (Owner/Admin) and policy disabled state via the factory's Enforce predicate. + /// + /// The organization ID to check. + /// True if the user is eligible for self-revocation (policy applies to them), false otherwise. + /// + /// Self-revoke is used to opt out of migrating the user's personal vault to the organization as required by this policy. + /// + public bool EligibleForSelfRevoke(Guid organizationId) + { + var policyDetail = _policyDetails + .FirstOrDefault(p => p.OrganizationId == organizationId); + + return policyDetail?.HasStatus([OrganizationUserStatusType.Confirmed]) ?? false; + } } public record DefaultCollectionRequest(Guid OrganizationUserId, bool ShouldCreateDefaultCollection) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index f69935715d..6e0c3aa8d9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddPolicyValidators(); diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs index 86c94147f4..213d18c27d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs @@ -74,8 +74,12 @@ public class AutomaticUserConfirmationPolicyEventHandler( private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId, ICollection organizationUsers) { - var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync( - organizationUsers.Select(ou => ou.UserId!.Value))) + var userIds = organizationUsers.Where( + u => u.UserId is not null && + u.Status != OrganizationUserStatusType.Invited) + .Select(u => u.UserId!.Value); + + var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds)) .Any(uo => uo.OrganizationId != organizationId && uo.Status != OrganizationUserStatusType.Invited); diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs index 0bee2a55af..104a5751ff 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs @@ -6,15 +6,13 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Repositories; -using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; public class OrganizationDataOwnershipPolicyValidator( IPolicyRepository policyRepository, ICollectionRepository collectionRepository, - IEnumerable> factories, - IFeatureService featureService) + IEnumerable> factories) : OrganizationPolicyValidator(policyRepository, factories), IPostSavePolicySideEffect, IOnPolicyPostUpdateEvent { public PolicyType Type => PolicyType.OrganizationDataOwnership; @@ -32,11 +30,6 @@ public class OrganizationDataOwnershipPolicyValidator( Policy postUpdatedPolicy, Policy? previousPolicyState) { - if (!featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) - { - return; - } - if (policyRequest.Metadata is not OrganizationModelOwnershipPolicyModel metadata) { return; @@ -64,14 +57,15 @@ public class OrganizationDataOwnershipPolicyValidator( var userOrgIds = requirements .Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId)) .Where(request => request.ShouldCreateDefaultCollection) - .Select(request => request.OrganizationUserId); + .Select(request => request.OrganizationUserId) + .ToList(); if (!userOrgIds.Any()) { return; } - await collectionRepository.UpsertDefaultCollectionsAsync( + await collectionRepository.CreateDefaultCollectionsBulkAsync( policyUpdate.OrganizationId, userOrgIds, defaultCollectionName); diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index da7a77000b..d79923fdd1 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -21,7 +21,9 @@ public interface IOrganizationRepository : IRepository Task> GetOwnerEmailAddressesById(Guid organizationId); /// - /// Gets the organizations that have a verified domain matching the user's email domain. + /// Gets the organizations that have claimed the user's account. Currently, only one organization may claim a user. + /// This requires that the organization has claimed the user's domain and the user is an organization member. + /// It excludes invited members. /// Task> GetByVerifiedUserEmailDomainAsync(Guid userId); diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index f509ac8358..af0d327a68 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -27,7 +27,6 @@ public interface IOrganizationService OrganizationUserInvite invite, string externalId); Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites); - Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId); Task DeleteSsoUserAsync(Guid userId, Guid? organizationId); Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index e1fcbb970d..d87bc65042 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -14,7 +14,6 @@ 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; @@ -49,7 +48,7 @@ public class OrganizationService : IOrganizationService private readonly IEventService _eventService; private readonly IApplicationCacheService _applicationCacheService; private readonly IStripePaymentService _paymentService; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IPolicyService _policyService; private readonly ISsoUserRepository _ssoUserRepository; private readonly IGlobalSettings _globalSettings; @@ -76,7 +75,7 @@ public class OrganizationService : IOrganizationService IEventService eventService, IApplicationCacheService applicationCacheService, IStripePaymentService paymentService, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IPolicyService policyService, ISsoUserRepository ssoUserRepository, IGlobalSettings globalSettings, @@ -103,7 +102,7 @@ public class OrganizationService : IOrganizationService _eventService = eventService; _applicationCacheService = applicationCacheService; _paymentService = paymentService; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _policyService = policyService; _ssoUserRepository = ssoUserRepository; _globalSettings = globalSettings; @@ -718,32 +717,6 @@ public class OrganizationService : IOrganizationService return (allOrgUsers, events); } - public async Task>> ResendInvitesAsync(Guid organizationId, - Guid? invitingUserId, - IEnumerable organizationUsersId) - { - var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId); - _logger.LogUserInviteStateDiagnostics(orgUsers); - - var org = await GetOrgById(organizationId); - - var result = new List>(); - foreach (var orgUser in orgUsers) - { - if (orgUser.Status != OrganizationUserStatusType.Invited || orgUser.OrganizationId != organizationId) - { - result.Add(Tuple.Create(orgUser, "User invalid.")); - continue; - } - - await SendInviteAsync(orgUser, org, false); - result.Add(Tuple.Create(orgUser, "")); - } - - return result; - } - - private async Task SendInvitesAsync(IEnumerable orgUsers, Organization organization) => await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization)); @@ -862,9 +835,8 @@ public class OrganizationService : IOrganizationService } // Make sure the organization has the policy enabled - var resetPasswordPolicy = - await _policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.ResetPassword); - if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled) + var resetPasswordPolicy = await _policyQuery.RunAsync(organizationId, PolicyType.ResetPassword); + if (!resetPasswordPolicy.Enabled) { throw new BadRequestException("Organization does not have the password reset policy enabled."); } diff --git a/src/Core/AdminConsole/Services/OrganizationFactory.cs b/src/Core/AdminConsole/Services/OrganizationFactory.cs index 0c64a27431..fadafd179c 100644 --- a/src/Core/AdminConsole/Services/OrganizationFactory.cs +++ b/src/Core/AdminConsole/Services/OrganizationFactory.cs @@ -62,6 +62,8 @@ public static class OrganizationFactory UseAdminSponsoredFamilies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAdminSponsoredFamilies), UseAutomaticUserConfirmation = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAutomaticUserConfirmation), + UseDisableSmAdsForUsers = + claimsPrincipal.GetValue(OrganizationLicenseConstants.UseDisableSmAdsForUsers), UsePhishingBlocker = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePhishingBlocker), }; @@ -113,6 +115,7 @@ public static class OrganizationFactory UseOrganizationDomains = license.UseOrganizationDomains, UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies, UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation, + UseDisableSmAdsForUsers = license.UseDisableSmAdsForUsers, UsePhishingBlocker = license.UsePhishingBlocker, }; } diff --git a/src/Core/AdminConsole/Utilities/PolicyDataValidator.cs b/src/Core/AdminConsole/Utilities/PolicyDataValidator.cs index 84e63f2a20..d533ca88cf 100644 --- a/src/Core/AdminConsole/Utilities/PolicyDataValidator.cs +++ b/src/Core/AdminConsole/Utilities/PolicyDataValidator.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; @@ -30,7 +31,8 @@ public static class PolicyDataValidator switch (policyType) { case PolicyType.MasterPassword: - CoreHelpers.LoadClassFromJsonData(json); + var masterPasswordData = CoreHelpers.LoadClassFromJsonData(json); + ValidateModel(masterPasswordData, policyType); break; case PolicyType.SendOptions: CoreHelpers.LoadClassFromJsonData(json); @@ -44,11 +46,24 @@ public static class PolicyDataValidator } catch (JsonException ex) { - var fieldInfo = !string.IsNullOrEmpty(ex.Path) ? $": field '{ex.Path}' has invalid type" : ""; + var fieldName = !string.IsNullOrEmpty(ex.Path) ? ex.Path.TrimStart('$', '.') : null; + var fieldInfo = !string.IsNullOrEmpty(fieldName) ? $": {fieldName} has an invalid value" : ""; throw new BadRequestException($"Invalid data for {policyType} policy{fieldInfo}."); } } + private static void ValidateModel(object model, PolicyType policyType) + { + var validationContext = new ValidationContext(model); + var validationResults = new List(); + + if (!Validator.TryValidateObject(model, validationContext, validationResults, true)) + { + var errors = string.Join(", ", validationResults.Select(r => r.ErrorMessage)); + throw new BadRequestException($"Invalid data for {policyType} policy: {errors}"); + } + } + /// /// Validates and deserializes policy metadata based on the policy type. /// diff --git a/src/Core/Auth/Entities/EmergencyAccess.cs b/src/Core/Auth/Entities/EmergencyAccess.cs index d855126468..36aaf46a8c 100644 --- a/src/Core/Auth/Entities/EmergencyAccess.cs +++ b/src/Core/Auth/Entities/EmergencyAccess.cs @@ -18,7 +18,7 @@ public class EmergencyAccess : ITableObject public string KeyEncrypted { get; set; } public EmergencyAccessType Type { get; set; } public EmergencyAccessStatusType Status { get; set; } - public int WaitTimeDays { get; set; } + public short WaitTimeDays { get; set; } public DateTime? RecoveryInitiatedDate { get; set; } public DateTime? LastNotificationDate { get; set; } public DateTime CreationDate { get; set; } = DateTime.UtcNow; diff --git a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs index aa8a298200..bc22ab1266 100644 --- a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs +++ b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs @@ -45,13 +45,19 @@ public class WebAuthnPrfDecryptionOption { public string EncryptedPrivateKey { get; } public string EncryptedUserKey { get; } + public string CredentialId { get; } + public string[] Transports { get; } public WebAuthnPrfDecryptionOption( string encryptedPrivateKey, - string encryptedUserKey) + string encryptedUserKey, + string credentialId, + string[]? transports = null) { EncryptedPrivateKey = encryptedPrivateKey; EncryptedUserKey = encryptedUserKey; + CredentialId = credentialId; + Transports = transports ?? []; } } diff --git a/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs b/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs new file mode 100644 index 0000000000..82bcb3da5e --- /dev/null +++ b/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs @@ -0,0 +1,23 @@ +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.Auth.Models.Data; + +/// +/// Data model for setting an initial master password for a user. +/// +public class SetInitialMasterPasswordDataModel +{ + public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } + public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } + + /// + /// Organization SSO identifier. + /// + public required string OrgSsoIdentifier { get; set; } + + /// + /// User account keys. Required for Master Password decryption user. + /// + public required UserAccountKeysData? AccountKeys { get; set; } + public string? MasterPasswordHint { get; set; } +} diff --git a/src/Core/Auth/Services/Implementations/SsoConfigService.cs b/src/Core/Auth/Services/Implementations/SsoConfigService.cs index 0cb8b68042..3c4f1ef85d 100644 --- a/src/Core/Auth/Services/Implementations/SsoConfigService.cs +++ b/src/Core/Auth/Services/Implementations/SsoConfigService.cs @@ -5,9 +5,9 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -21,7 +21,7 @@ namespace Bit.Core.Auth.Services; public class SsoConfigService : ISsoConfigService { private readonly ISsoConfigRepository _ssoConfigRepository; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IEventService _eventService; @@ -29,14 +29,14 @@ public class SsoConfigService : ISsoConfigService public SsoConfigService( ISsoConfigRepository ssoConfigRepository, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IEventService eventService, IVNextSavePolicyCommand vNextSavePolicyCommand) { _ssoConfigRepository = ssoConfigRepository; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _eventService = eventService; @@ -114,14 +114,14 @@ public class SsoConfigService : ISsoConfigService throw new BadRequestException("Organization cannot use Key Connector."); } - var singleOrgPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.SingleOrg); - if (singleOrgPolicy is not { Enabled: true }) + var singleOrgPolicy = await _policyQuery.RunAsync(config.OrganizationId, PolicyType.SingleOrg); + if (!singleOrgPolicy.Enabled) { throw new BadRequestException("Key Connector requires the Single Organization policy to be enabled."); } - var ssoPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.RequireSso); - if (ssoPolicy is not { Enabled: true }) + var ssoPolicy = await _policyQuery.RunAsync(config.OrganizationId, PolicyType.RequireSso); + if (!ssoPolicy.Enabled) { throw new BadRequestException("Key Connector requires the Single Sign-On Authentication policy to be enabled."); } diff --git a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs similarity index 95% rename from src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs rename to src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs index 4331179554..6552f4bc69 100644 --- a/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; @@ -19,7 +18,7 @@ using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; -namespace Bit.Core.Auth.Services; +namespace Bit.Core.Auth.UserFeatures.EmergencyAccess; public class EmergencyAccessService : IEmergencyAccessService { @@ -61,7 +60,7 @@ public class EmergencyAccessService : IEmergencyAccessService _removeOrganizationUserCommand = removeOrganizationUserCommand; } - public async Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime) + public async Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime) { if (!await _userService.CanAccessPremium(grantorUser)) { @@ -73,13 +72,13 @@ public class EmergencyAccessService : IEmergencyAccessService throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector."); } - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Entities.EmergencyAccess { GrantorId = grantorUser.Id, Email = emergencyContactEmail.ToLowerInvariant(), Status = EmergencyAccessStatusType.Invited, Type = accessType, - WaitTimeDays = waitTime, + WaitTimeDays = (short)waitTime, CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow, }; @@ -113,7 +112,7 @@ public class EmergencyAccessService : IEmergencyAccessService await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser)); } - public async Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService) + public async Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); if (emergencyAccess == null) @@ -175,7 +174,7 @@ public class EmergencyAccessService : IEmergencyAccessService await _emergencyAccessRepository.DeleteAsync(emergencyAccess); } - public async Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId) + public async Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted || @@ -201,7 +200,7 @@ public class EmergencyAccessService : IEmergencyAccessService return emergencyAccess; } - public async Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser) + public async Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser) { if (!await _userService.CanAccessPremium(grantorUser)) { @@ -311,7 +310,7 @@ public class EmergencyAccessService : IEmergencyAccessService } // TODO PM-21687: rename this to something like InitiateRecoveryTakeoverAsync - public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser) + public async Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); @@ -429,7 +428,7 @@ public class EmergencyAccessService : IEmergencyAccessService return await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId); } - private async Task SendInviteAsync(EmergencyAccess emergencyAccess, string invitingUsersName) + private async Task SendInviteAsync(Entities.EmergencyAccess emergencyAccess, string invitingUsersName) { var token = _dataProtectorTokenizer.Protect(new EmergencyAccessInviteTokenable(emergencyAccess, _globalSettings.OrganizationInviteExpirationHours)); await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token); @@ -449,7 +448,7 @@ public class EmergencyAccessService : IEmergencyAccessService */ //TODO PM-21687: this IsValidRequest() checks the validity based on the granteeUser. There should be a complementary method for the grantorUser private static bool IsValidRequest( - EmergencyAccess availableAccess, + Entities.EmergencyAccess availableAccess, User requestingUser, EmergencyAccessType requestedAccessType) { diff --git a/src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs similarity index 93% rename from src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs rename to src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs index de695bbd7d..860ae8bfb6 100644 --- a/src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs @@ -1,5 +1,4 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; @@ -7,7 +6,7 @@ using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Vault.Models.Data; -namespace Bit.Core.Auth.Services; +namespace Bit.Core.Auth.UserFeatures.EmergencyAccess; public interface IEmergencyAccessService { @@ -20,7 +19,7 @@ public interface IEmergencyAccessService /// Type of emergency access allowed to the emergency contact /// The amount of time to pass before the invite is auto confirmed /// a new Emergency Access object - Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime); + Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime); /// /// Sends an invite to the emergency contact associated with the emergency access id. /// @@ -37,7 +36,7 @@ public interface IEmergencyAccessService /// the tokenable that was sent via email /// service dependency /// void - Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService); + Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService); /// /// The creator of the emergency access request can delete the request. /// @@ -53,7 +52,7 @@ public interface IEmergencyAccessService /// The grantor user key encrypted by the grantee public key; grantee.PubicKey(grantor.User.Key) /// Id of grantor user /// emergency access object associated with the Id passed in - Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId); + Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId); /// /// Fetches an emergency access object. The grantor user must own the object being fetched. /// @@ -67,7 +66,7 @@ public interface IEmergencyAccessService /// emergency access entity being updated /// grantor user /// void - Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser); + Task SaveAsync(Entities.EmergencyAccess emergencyAccess, User grantorUser); /// /// Initiates the recovery process. For either Takeover or view. Will send an email to the Grantor User notifying of the initiation. /// @@ -107,7 +106,7 @@ public interface IEmergencyAccessService /// Id of entity being accessed /// grantee user of the emergency access entity /// emergency access entity and the grantorUser - Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser); + Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser); /// /// Updates the grantor's password hash and updates the key for the EmergencyAccess entity. /// diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs new file mode 100644 index 0000000000..4d60556785 --- /dev/null +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.cs @@ -0,0 +1,14 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail; + +public class EmergencyAccessRemoveGranteesMailView : BaseMailView +{ + public required IEnumerable RemovedGranteeNames { get; set; } + public string EmergencyAccessHelpPageUrl => "https://bitwarden.com/help/emergency-access/"; +} + +public class EmergencyAccessRemoveGranteesMail : BaseMail +{ + public override string Subject { get; set; } = "Emergency contacts removed"; +} diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs similarity index 55% rename from src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs rename to src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs index 29977724d4..405f2744bd 100644 --- a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/Mail/EmergencyAccessRemoveGranteesMailView.html.hbs @@ -30,30 +30,18 @@ - - - - - - @@ -68,30 +56,11 @@ } - @media only screen and (max-width:480px) { - .mj-bw-learn-more-footer-responsive-img { - display: none !important; - } - } - - @media only screen and (max-width:479px) { table.mj-full-width-mobile { width: 100% !important; } td.mj-full-width-mobile { width: auto !important; } } - - @media only screen and (max-width:480px) { - .mj-bw-icon-row-text { - padding-left: 5px !important; - line-height: 20px; - } - .mj-bw-icon-row { - padding: 10px 15px; - width: fit-content !important; - } - } - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - -
- - - - - - - -
- - - - - - - - -
- - - - - -
- - - - - - - -
- - -
- - - - - - - - - - - - - - - - - -
- - - - - - - -
- - - -
- -
- -

- You can now share passwords with members of {{OrganizationName}}! -

- -
- - - - - - - -
- - Log in - -
- -
- -
- - - -
- - - - - - - - - -
- - - - - - - -
- - - -
- -
- -
- - -
- -
- - - - - -
- - -
- -
- - - - - - - - - -
- - - - - - - -
- - - -
- - - - - - - -
- - -
- - - - - - - - - -
- -
As a member of {{OrganizationName}}:
- -
- -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Collections Icon - -
- -
- -
- - - -
- - - - - - - - - -
- -
You can access passwords {{OrganizationName}} has shared with you.
- -
- -
- - -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - Group Users Icon - -
- -
- -
- - - -
- - - - - - - - - - - - - -
- -
You can easily share passwords with friends, family, or coworkers.
- -
- - - -
- -
- - -
- - -
- -
- - - - - -
- - - - - - - -
- -
- -
- - - -
- -
- - - - - - - - - -
- - - - - - - -
- - - -
- - - - - - - -
- - -
- - - - - - - - - - - - - -
- -
Download Bitwarden on all devices
- -
- -
Already using the browser extension? - Download the Bitwarden mobile app from the - App Store - or Google Play - to quickly save logins and autofill forms on the go.
- -
- -
- - -
- -
- - - - - -
- - - - - - - -
- - -
- - -
- - - - - - - - - -
- - - - - - - -
- - - - Download on the App Store - - - -
- -
- -
- - - -
- - - - - - - - - -
- - - - - - - -
- - - - Get it on Google Play - - - -
- -
- -
- - -
- - -
- -
- - - -
- -
- - - - - - - - - -
- - - - - - - -
- - - -
- - - - - - - -
- - -
- - - - - - - - - -
- -

- Learn more about Bitwarden -

- Find user guides, product documentation, and videos on the - Bitwarden Help Center.
- -
- -
- - - -
- - - - - - - - - -
- -
- - -
- -
- - - -
- -
- - - - - - - - - -
- - - - - - - -
- - -
- - - - - - - - - - - - - -
- - - - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - - - - - -
- - - - - - -
- - - -
-
- - - -
- -

- © 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 -

- -
- -
- - -
- -
- - - - - -
- - - - \ No newline at end of file diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml index 24f85af31c..b94bf0dc86 100644 --- a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.mjml @@ -8,9 +8,9 @@ @@ -33,7 +33,7 @@ icon-alt="Group Users Icon" text="You can easily access and share passwords with your team." foot-url-text="Share passwords in Bitwarden" - foot-url="https://bitwarden.com/help/share-to-a-collection/" + foot-url="https://bitwarden.com/help/sharing" /> diff --git a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml index 2e48e82f84..c223e2f650 100644 --- a/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml +++ b/src/Core/MailTemplates/Mjml/emails/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.mjml @@ -8,9 +8,9 @@ @@ -33,7 +33,7 @@ icon-alt="Group Users Icon" text="You can easily share passwords with friends, family, or coworkers." foot-url-text="Share passwords in Bitwarden" - foot-url="https://bitwarden.com/help/share-to-a-collection/" + foot-url="https://bitwarden.com/help/sharing" /> diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/UserFeatures/EmergencyAccess/emergency-access-remove-grantees.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/UserFeatures/EmergencyAccess/emergency-access-remove-grantees.mjml new file mode 100644 index 0000000000..3af29a4414 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Auth/UserFeatures/EmergencyAccess/emergency-access-remove-grantees.mjml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + The following emergency contacts have been removed from your account: +
    + {{#each RemovedGranteeNames}} +
  • {{this}}
  • + {{/each}} +
+ Learn more about emergency access. +
+
+
+
+ + + +
+
diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml index 092ae303de..06f60e7724 100644 --- a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/families-2019-renewal.mjml @@ -18,7 +18,7 @@ at {{BaseAnnualRenewalPrice}} + tax. - As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. + As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal. This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. diff --git a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml index a460442a7c..defec91f0e 100644 --- a/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Billing/Renewals/premium-renewal.mjml @@ -17,8 +17,8 @@ Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually. - As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. - This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually. + As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal. + This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. Questions? Contact diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs index e3aff02f5d..832a211c8e 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.cs @@ -12,5 +12,5 @@ public class Families2019RenewalMailView : BaseMailView public class Families2019RenewalMail : BaseMail { - public override string Subject { get => "Your Bitwarden Families renewal is updating"; } + public override string Subject { get; set; } = "Your Bitwarden Families renewal is updating"; } diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs index 227613999b..2d7c9edf35 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.html.hbs @@ -202,7 +202,7 @@ -
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. +
As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal. This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
@@ -271,12 +271,12 @@ - + -
+
- +
@@ -364,8 +364,8 @@ - -
- + +
@@ -381,13 +381,13 @@
- +
+ - @@ -404,13 +404,13 @@ -
+ - +
- +
+ - @@ -427,13 +427,13 @@ -
+ - +
- +
+ - @@ -450,13 +450,13 @@ -
+ - +
- +
+ - @@ -473,13 +473,13 @@ -
+ - +
- +
+ - @@ -496,13 +496,13 @@ -
+ - +
- +
+ - @@ -519,13 +519,13 @@ -
+ - +
- +
+ - @@ -546,15 +546,15 @@ diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs index 88d64f9acf..9f40c88329 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/Families2019Renewal/Families2019RenewalMailView.text.hbs @@ -1,7 +1,7 @@ Your Bitwarden Families subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually at {{BaseAnnualRenewalPrice}} + tax. -As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. +As a long time Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal. This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. Questions? Contact support@bitwarden.com diff --git a/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs index eb7bef4322..e36c584bdc 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs +++ b/src/Core/Models/Mail/Billing/Renewal/Families2020Renewal/Families2020RenewalMailView.cs @@ -9,5 +9,5 @@ public class Families2020RenewalMailView : BaseMailView public class Families2020RenewalMail : BaseMail { - public override string Subject { get => "Your Bitwarden Families renewal is updating"; } + public override string Subject { get; set; } = "Your Bitwarden Families renewal is updating"; } diff --git a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs index e231a44467..0798c7dbc8 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs +++ b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.cs @@ -5,11 +5,11 @@ namespace Bit.Core.Models.Mail.Billing.Renewal.Premium; public class PremiumRenewalMailView : BaseMailView { public required string BaseMonthlyRenewalPrice { get; set; } - public required string DiscountedMonthlyRenewalPrice { get; set; } + public required string DiscountedAnnualRenewalPrice { get; set; } public required string DiscountAmount { get; set; } } public class PremiumRenewalMail : BaseMail { - public override string Subject { get => "Your Bitwarden Premium renewal is updating"; } + public override string Subject { get; set; } = "Your Bitwarden Premium renewal is updating"; } diff --git a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs index a6b2fda0f7..db76520eed 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.html.hbs @@ -201,8 +201,8 @@ @@ -270,12 +270,12 @@
+ - +
-

+

© 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 + bitwarden.com | + Learn why we include this

-
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. - This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually.
+
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal. + This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax.
- + -
+
- +
@@ -363,8 +363,8 @@ - -
- + +
@@ -380,13 +380,13 @@
- +
+ - @@ -403,13 +403,13 @@ -
+ - +
- +
+ - @@ -426,13 +426,13 @@ -
+ - +
- +
+ - @@ -449,13 +449,13 @@ -
+ - +
- +
+ - @@ -472,13 +472,13 @@ -
+ - +
- +
+ - @@ -495,13 +495,13 @@ -
+ - +
- +
+ - @@ -518,13 +518,13 @@ -
+ - +
- +
+ - @@ -545,15 +545,15 @@ diff --git a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs index 41300d0f96..4b79826f71 100644 --- a/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs +++ b/src/Core/Models/Mail/Billing/Renewal/Premium/PremiumRenewalMailView.text.hbs @@ -1,6 +1,6 @@ Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually. -As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal. -This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually. +As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this year's renewal. +This renewal will now be billed annually at {{DiscountedAnnualRenewalPrice}} + tax. Questions? Contact support@bitwarden.com diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index b502cc6e4e..c1ebc65d44 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -23,7 +23,9 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.V using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; using Bit.Core.Models.Business.Tokenables; using Bit.Core.OrganizationFeatures.OrganizationCollections; using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; @@ -44,7 +46,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; - using V1_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; using V2_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; @@ -139,6 +140,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -150,6 +152,8 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + + services.AddScoped(); } private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services) diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 4ad63bd8d7..9c06ce1709 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -30,6 +31,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand private readonly IGroupRepository _groupRepository; private readonly IStripePaymentService _paymentService; private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly IOrganizationConnectionRepository _organizationConnectionRepository; private readonly IServiceAccountRepository _serviceAccountRepository; @@ -45,6 +47,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand IGroupRepository groupRepository, IStripePaymentService paymentService, IPolicyRepository policyRepository, + IPolicyQuery policyQuery, ISsoConfigRepository ssoConfigRepository, IOrganizationConnectionRepository organizationConnectionRepository, IServiceAccountRepository serviceAccountRepository, @@ -59,6 +62,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand _groupRepository = groupRepository; _paymentService = paymentService; _policyRepository = policyRepository; + _policyQuery = policyQuery; _ssoConfigRepository = ssoConfigRepository; _organizationConnectionRepository = organizationConnectionRepository; _serviceAccountRepository = serviceAccountRepository; @@ -184,9 +188,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand if (!newPlan.HasResetPassword && organization.UseResetPassword) { - var resetPasswordPolicy = - await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); - if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled) + var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword); + if (resetPasswordPolicy.Enabled) { throw new BadRequestException("Your new plan does not allow the Password Reset feature. " + "Disable your Password Reset policy."); diff --git a/src/Core/Platform/Mail/HandlebarsMailService.cs b/src/Core/Platform/Mail/HandlebarsMailService.cs index d57ca400fd..e92fa34daa 100644 --- a/src/Core/Platform/Mail/HandlebarsMailService.cs +++ b/src/Core/Platform/Mail/HandlebarsMailService.cs @@ -657,11 +657,11 @@ public class HandlebarsMailService : IMailService return; MailQueueMessage CreateMessage(string emailAddress, Organization org) => - new(CreateDefaultMessage($"Your Bitwarden account is claimed by {org.DisplayName()}", emailAddress), + new(CreateDefaultMessage($"Important update to your Bitwarden account", emailAddress), "AdminConsole.DomainClaimedByOrganization", new ClaimedDomainUserNotificationViewModel { - TitleFirst = $"Your Bitwarden account is claimed by {org.DisplayName()}", + TitleFirst = $"Important update to your Bitwarden account", OrganizationName = CoreHelpers.SanitizeForEmail(org.DisplayName(), false) }); } diff --git a/src/Core/Platform/Mail/Mailer/BaseMail.cs b/src/Core/Platform/Mail/Mailer/BaseMail.cs index 0fd6b79aba..4983510ec8 100644 --- a/src/Core/Platform/Mail/Mailer/BaseMail.cs +++ b/src/Core/Platform/Mail/Mailer/BaseMail.cs @@ -19,7 +19,7 @@ public abstract class BaseMail where TView : BaseMailView /// /// The subject of the email. /// - public abstract string Subject { get; } + public abstract string Subject { get; set; } /// /// An optional category for processing at the upstream email delivery service. diff --git a/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs index baba5b8015..8b4e0bd5df 100644 --- a/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs +++ b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs @@ -124,8 +124,20 @@ public class HandlebarMailRenderer : IMailRenderer // TODO: Do we still need layouts with MJML? var assembly = typeof(HandlebarMailRenderer).Assembly; - var layoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.Full.html.hbs"); - handlebars.RegisterTemplate("FullHtmlLayout", layoutSource); + + // Register Full layouts + var fullHtmlLayoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.Full.html.hbs"); + handlebars.RegisterTemplate("FullHtmlLayout", fullHtmlLayoutSource); + + var fullTextLayoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.Full.text.hbs"); + handlebars.RegisterTemplate("FullTextLayout", fullTextLayoutSource); + + // Register TitleContactUs layouts + var titleContactUsHtmlLayoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.TitleContactUs.html.hbs"); + handlebars.RegisterTemplate("TitleContactUsHtmlLayout", titleContactUsHtmlLayoutSource); + + var titleContactUsTextLayoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.TitleContactUs.text.hbs"); + handlebars.RegisterTemplate("TitleContactUsTextLayout", titleContactUsTextLayoutSource); return handlebars; } diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index f86147ca7d..3f3b71d2d5 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -64,11 +64,22 @@ public interface ICollectionRepository : IRepository IEnumerable users, IEnumerable groups); /// - /// Creates default user collections for the specified organization users if they do not already have one. + /// Creates default user collections for the specified organization users. + /// Filters internally to only create collections for users who don't 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); + Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName); + + /// + /// Creates default user collections for the specified organization users using bulk insert operations. + /// Use this if you need to create collections for > ~1k users. + /// Filters internally to only create collections for users who don't 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 CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName); + } diff --git a/src/Core/Repositories/IPlayItemRepository.cs b/src/Core/Repositories/IPlayItemRepository.cs new file mode 100644 index 0000000000..fb00be2bc1 --- /dev/null +++ b/src/Core/Repositories/IPlayItemRepository.cs @@ -0,0 +1,11 @@ +using Bit.Core.Entities; + +#nullable enable + +namespace Bit.Core.Repositories; + +public interface IPlayItemRepository : IRepository +{ + Task> GetByPlayIdAsync(string playId); + Task DeleteByPlayIdAsync(string playId); +} diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 93316d78bd..c5f78d76fc 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -74,6 +74,24 @@ public interface IUserRepository : IRepository Task DeleteManyAsync(IEnumerable users); UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey); + + /// + /// Sets the master password and KDF for a user. + /// + /// The user identifier. + /// Data for unlocking with the master password. + /// Server side hash of the user master authentication password hash + /// Optional hint for the master password. + /// A task to complete the operation. + UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData, + string serverSideHashedMasterPasswordAuthenticationHash, string? masterPasswordHint); + + /// + /// Updates multiple user data properties in a single transaction. + /// + /// Actions to update user data. + /// On success + Task UpdateUserDataAsync(IEnumerable updateUserDataActions); } public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null, diff --git a/src/Core/Services/IBraintreeService.cs b/src/Core/Services/IBraintreeService.cs new file mode 100644 index 0000000000..166d285908 --- /dev/null +++ b/src/Core/Services/IBraintreeService.cs @@ -0,0 +1,11 @@ +using Bit.Core.Billing.Subscriptions.Models; +using Stripe; + +namespace Bit.Core.Services; + +public interface IBraintreeService +{ + Task PayInvoice( + SubscriberId subscriberId, + Invoice invoice); +} diff --git a/src/Core/Services/Implementations/BraintreeService.cs b/src/Core/Services/Implementations/BraintreeService.cs new file mode 100644 index 0000000000..e3630ed888 --- /dev/null +++ b/src/Core/Services/Implementations/BraintreeService.cs @@ -0,0 +1,107 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Exceptions; +using Bit.Core.Settings; +using Braintree; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Services.Implementations; + +using static StripeConstants; + +public class BraintreeService( + IBraintreeGateway braintreeGateway, + IGlobalSettings globalSettings, + ILogger logger, + IMailService mailService, + IStripeAdapter stripeAdapter) : IBraintreeService +{ + private readonly ConflictException _problemPayingInvoice = new("There was a problem paying for your invoice. Please contact customer support."); + + public async Task PayInvoice( + SubscriberId subscriberId, + Invoice invoice) + { + if (invoice.Customer == null) + { + logger.LogError("Invoice's ({InvoiceID}) `customer` property must be expanded to be paid with Braintree", + invoice.Id); + throw _problemPayingInvoice; + } + + if (!invoice.Customer.Metadata.TryGetValue(MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) + { + logger.LogError( + "Cannot pay invoice ({InvoiceID}) with Braintree for Customer ({CustomerID}) that does not have a Braintree Customer ID", + invoice.Id, invoice.Customer.Id); + throw _problemPayingInvoice; + } + + if (invoice is not + { + AmountDue: > 0, + Status: not InvoiceStatus.Paid, + CollectionMethod: CollectionMethod.ChargeAutomatically + }) + { + logger.LogWarning("Attempted to pay invoice ({InvoiceID}) with Braintree that is not eligible for payment", invoice.Id); + return; + } + + var amount = Math.Round(invoice.AmountDue / 100M, 2); + + var idKey = subscriberId.Match( + _ => "user_id", + _ => "organization_id", + _ => "provider_id"); + + var idValue = subscriberId.Match( + userId => userId.Value, + organizationId => organizationId.Value, + providerId => providerId.Value); + + var request = new TransactionRequest + { + Amount = amount, + CustomerId = braintreeCustomerId, + Options = new TransactionOptionsRequest + { + SubmitForSettlement = true, + PayPal = new TransactionOptionsPayPalRequest + { + CustomField = $"{idKey}:{idValue},region:{globalSettings.BaseServiceUri.CloudRegion}" + } + }, + CustomFields = new Dictionary + { + [idKey] = idValue.ToString(), + ["region"] = globalSettings.BaseServiceUri.CloudRegion + } + }; + + var result = await braintreeGateway.Transaction.SaleAsync(request); + + if (!result.IsSuccess()) + { + if (invoice.AttemptCount < 4) + { + await mailService.SendPaymentFailedAsync(invoice.Customer.Email, amount, true); + } + + return; + } + + await stripeAdapter.UpdateInvoiceAsync(invoice.Id, new InvoiceUpdateOptions + { + Metadata = new Dictionary + { + [MetadataKeys.BraintreeTransactionId] = result.Target.Id, + [MetadataKeys.PayPalTransactionId] = result.Target.PayPalDetails.AuthorizationId + } + }); + + await stripeAdapter.PayInvoiceAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true }); + } +} diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 498721238b..5f87ee85d2 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -14,6 +14,8 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Sales; @@ -59,7 +61,7 @@ public class UserService : UserManager, IUserService private readonly IEventService _eventService; private readonly IApplicationCacheService _applicationCacheService; private readonly IStripePaymentService _paymentService; - private readonly IPolicyRepository _policyRepository; + private readonly IPolicyQuery _policyQuery; private readonly IPolicyService _policyService; private readonly IFido2 _fido2; private readonly ICurrentContext _currentContext; @@ -96,7 +98,7 @@ public class UserService : UserManager, IUserService IEventService eventService, IApplicationCacheService applicationCacheService, IStripePaymentService paymentService, - IPolicyRepository policyRepository, + IPolicyQuery policyQuery, IPolicyService policyService, IFido2 fido2, ICurrentContext currentContext, @@ -137,7 +139,7 @@ public class UserService : UserManager, IUserService _eventService = eventService; _applicationCacheService = applicationCacheService; _paymentService = paymentService; - _policyRepository = policyRepository; + _policyQuery = policyQuery; _policyService = policyService; _fido2 = fido2; _currentContext = currentContext; @@ -720,9 +722,8 @@ public class UserService : UserManager, IUserService } // Enterprise policy must be enabled - var resetPasswordPolicy = - await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); - if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled) + var resetPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword); + if (!resetPasswordPolicy.Enabled) { throw new BadRequestException("Organization does not have the password reset policy enabled."); } @@ -982,6 +983,16 @@ public class UserService : UserManager, IUserService throw new BadRequestException(exceptionMessage); } + // If the license has a Token (claims-based), extract all properties from claims + // Otherwise, fall back to using the properties already on the license object (backward compatibility) + if (claimsPrincipal != null) + { + license.LicenseKey = claimsPrincipal.GetValue(UserLicenseConstants.LicenseKey); + license.Premium = claimsPrincipal.GetValue(UserLicenseConstants.Premium); + license.MaxStorageGb = claimsPrincipal.GetValue(UserLicenseConstants.MaxStorageGb); + license.Expires = claimsPrincipal.GetValue(UserLicenseConstants.Expires); + } + var dir = $"{_globalSettings.LicenseDirectory}/user"; Directory.CreateDirectory(dir); using var fs = File.OpenWrite(Path.Combine(dir, $"{user.Id}.json")); @@ -995,6 +1006,7 @@ public class UserService : UserManager, IUserService await SaveUserAsync(user); } + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page public async Task AdjustStorageAsync(User user, short storageAdjustmentGb) { if (user == null) @@ -1040,6 +1052,7 @@ public class UserService : UserManager, IUserService await _paymentService.CancelSubscriptionAsync(user, eop); } + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page public async Task ReinstatePremiumAsync(User user) { await _paymentService.ReinstateSubscriptionAsync(user); diff --git a/src/Core/Services/Play/IPlayIdService.cs b/src/Core/Services/Play/IPlayIdService.cs new file mode 100644 index 0000000000..542de9725f --- /dev/null +++ b/src/Core/Services/Play/IPlayIdService.cs @@ -0,0 +1,23 @@ +namespace Bit.Core.Services; + +/// +/// Service for managing Play identifiers in automated testing infrastructure. +/// A "Play" is a test session that groups entities created during testing to enable cleanup. +/// The PlayId flows from client request (x-play-id header) through PlayIdMiddleware to this service, +/// which repositories query to create PlayItem tracking records via IPlayItemService. The SeederAPI uses these records +/// to bulk delete all entities associated with a PlayId. Only active in Development environments. +/// +public interface IPlayIdService +{ + /// + /// Gets or sets the current Play identifier from the x-play-id request header. + /// + string? PlayId { get; set; } + + /// + /// Checks whether the current request is part of an active Play session. + /// + /// The Play identifier if active, otherwise empty string. + /// True if in a Play session (has PlayId and in Development environment), otherwise false. + bool InPlay(out string playId); +} diff --git a/src/Core/Services/Play/IPlayItemService.cs b/src/Core/Services/Play/IPlayItemService.cs new file mode 100644 index 0000000000..5033aaf626 --- /dev/null +++ b/src/Core/Services/Play/IPlayItemService.cs @@ -0,0 +1,27 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; + +namespace Bit.Core.Services; + +/// +/// Service used to track added users and organizations during a Play session. +/// +public interface IPlayItemService +{ + /// + /// Records a PlayItem entry for the given User created during a Play session. + /// + /// Does nothing if no Play Id is set for this http scope. + /// + /// + /// + Task Record(User user); + /// + /// Records a PlayItem entry for the given Organization created during a Play session. + /// + /// Does nothing if no Play Id is set for this http scope. + /// + /// + /// + Task Record(Organization organization); +} diff --git a/src/Core/Services/Play/Implementations/NeverPlayIdServices.cs b/src/Core/Services/Play/Implementations/NeverPlayIdServices.cs new file mode 100644 index 0000000000..baab44eedb --- /dev/null +++ b/src/Core/Services/Play/Implementations/NeverPlayIdServices.cs @@ -0,0 +1,16 @@ +namespace Bit.Core.Services; + +public class NeverPlayIdServices : IPlayIdService +{ + public string? PlayId + { + get => null; + set { } + } + + public bool InPlay(out string playId) + { + playId = string.Empty; + return false; + } +} diff --git a/src/Core/Services/Play/Implementations/PlayIdService.cs b/src/Core/Services/Play/Implementations/PlayIdService.cs new file mode 100644 index 0000000000..0c64f1dd14 --- /dev/null +++ b/src/Core/Services/Play/Implementations/PlayIdService.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Hosting; + +namespace Bit.Core.Services; + +public class PlayIdService(IHostEnvironment hostEnvironment) : IPlayIdService +{ + public string? PlayId { get; set; } + public bool InPlay(out string playId) + { + playId = PlayId ?? string.Empty; + return !string.IsNullOrEmpty(PlayId) && hostEnvironment.IsDevelopment(); + } +} diff --git a/src/Core/Services/Play/Implementations/PlayIdSingletonService.cs b/src/Core/Services/Play/Implementations/PlayIdSingletonService.cs new file mode 100644 index 0000000000..30bb30aad2 --- /dev/null +++ b/src/Core/Services/Play/Implementations/PlayIdSingletonService.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Bit.Core.Services; + +/// +/// Singleton wrapper service that bridges singleton-scoped service boundaries for PlayId tracking. +/// This allows singleton services to access the scoped PlayIdService via HttpContext.RequestServices. +/// +/// Uses IHttpContextAccessor to retrieve the current request's scoped PlayIdService instance, enabling +/// singleton services to participate in Play session tracking without violating DI lifetime rules. +/// Falls back to NeverPlayIdServices when no HttpContext is available (e.g., background jobs). +/// +public class PlayIdSingletonService(IHttpContextAccessor httpContextAccessor, IHostEnvironment hostEnvironment) : IPlayIdService +{ + private IPlayIdService Current + { + get + { + var httpContext = httpContextAccessor.HttpContext; + if (httpContext == null) + { + return new NeverPlayIdServices(); + } + return httpContext.RequestServices.GetRequiredService(); + } + } + + public string? PlayId + { + get => Current.PlayId; + set => Current.PlayId = value; + } + + public bool InPlay(out string playId) + { + if (hostEnvironment.IsDevelopment()) + { + return Current.InPlay(out playId); + } + else + { + playId = string.Empty; + return false; + } + } +} diff --git a/src/Core/Services/Play/Implementations/PlayItemService.cs b/src/Core/Services/Play/Implementations/PlayItemService.cs new file mode 100644 index 0000000000..981445f2ea --- /dev/null +++ b/src/Core/Services/Play/Implementations/PlayItemService.cs @@ -0,0 +1,26 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class PlayItemService(IPlayIdService playIdService, IPlayItemRepository playItemRepository, ILogger logger) : IPlayItemService +{ + public async Task Record(User user) + { + if (playIdService.InPlay(out var playId)) + { + logger.LogInformation("Associating user {UserId} with Play ID {PlayId}", user.Id, playId); + await playItemRepository.CreateAsync(PlayItem.Create(user, playId)); + } + } + public async Task Record(Organization organization) + { + if (playIdService.InPlay(out var playId)) + { + logger.LogInformation("Associating organization {OrganizationId} with Play ID {PlayId}", organization.Id, playId); + await playItemRepository.CreateAsync(PlayItem.Create(organization, playId)); + } + } +} diff --git a/src/Core/Services/Play/README.md b/src/Core/Services/Play/README.md new file mode 100644 index 0000000000..b04b6a13c7 --- /dev/null +++ b/src/Core/Services/Play/README.md @@ -0,0 +1,27 @@ +# Play Services + +## Overview + +The Play services provide automated testing infrastructure for tracking and cleaning up test data in development +environments. A "Play" is a test session that groups entities (users, organizations, etc.) created during testing to +enable bulk cleanup via the SeederAPI. + +## How It Works + +1. Test client sends `x-play-id` header with a unique Play identifier +2. `PlayIdMiddleware` extracts the header and sets it on `IPlayIdService` +3. Repositories check `IPlayIdService.InPlay()` when creating entities +4. `IPlayItemService` records PlayItem entries for tracked entities +5. SeederAPI uses PlayItem records to bulk delete all entities associated with a PlayId + +Play services are **only active in Development environments**. + +## Classes + +- **`IPlayIdService`** - Interface for managing Play identifiers in the current request scope +- **`IPlayItemService`** - Interface for tracking entities created during a Play session +- **`PlayIdService`** - Default scoped implementation for tracking Play sessions per HTTP request +- **`NeverPlayIdServices`** - No-op implementation used as fallback when no HttpContext is available +- **`PlayIdSingletonService`** - Singleton wrapper that allows singleton services to access scoped PlayIdService via + HttpContext +- **`PlayItemService`** - Implementation that records PlayItem entries for entities created during Play sessions diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 60a1fda19f..6ccbd1ee85 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -44,6 +44,7 @@ public class GlobalSettings : IGlobalSettings public virtual bool EnableCloudCommunication { get; set; } = false; public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days public virtual string EventGridKey { get; set; } + public virtual bool TestPlayIdTrackingEnabled { get; set; } = false; public virtual IInstallationSettings Installation { get; set; } = new InstallationSettings(); public virtual IBaseServiceUriSettings BaseServiceUri { get; set; } public virtual string DatabaseProvider { get; set; } @@ -82,7 +83,6 @@ public class GlobalSettings : IGlobalSettings public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings(); public virtual string DevelopmentDirectory { get; set; } public virtual IWebPushSettings WebPush { get; set; } = new WebPushSettings(); - public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5; public virtual bool EnableEmailVerification { get; set; } public virtual string KdfDefaultHashKey { get; set; } @@ -92,6 +92,7 @@ public class GlobalSettings : IGlobalSettings public virtual string SendDefaultHashKey { get; set; } public virtual string PricingUri { get; set; } public virtual Fido2Settings Fido2 { get; set; } = new Fido2Settings(); + public virtual ICommunicationSettings Communication { get; set; } = new CommunicationSettings(); public string BuildExternalUri(string explicitValue, string name) { @@ -775,4 +776,17 @@ public class GlobalSettings : IGlobalSettings { public HashSet Origins { get; set; } } + + public class CommunicationSettings : ICommunicationSettings + { + public string Bootstrap { get; set; } = "none"; + public ISsoCookieVendorSettings SsoCookieVendor { get; set; } = new SsoCookieVendorSettings(); + } + + public class SsoCookieVendorSettings : ISsoCookieVendorSettings + { + public string IdpLoginUrl { get; set; } + public string CookieName { get; set; } + public string CookieDomain { get; set; } + } } diff --git a/src/Core/Settings/ICommunicationSettings.cs b/src/Core/Settings/ICommunicationSettings.cs new file mode 100644 index 0000000000..26259a8448 --- /dev/null +++ b/src/Core/Settings/ICommunicationSettings.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Settings; + +public interface ICommunicationSettings +{ + string Bootstrap { get; set; } + ISsoCookieVendorSettings SsoCookieVendor { get; set; } +} diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index c316836d09..7f5323fac0 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -29,4 +29,5 @@ public interface IGlobalSettings IWebPushSettings WebPush { get; set; } GlobalSettings.EventLoggingSettings EventLogging { get; set; } GlobalSettings.WebAuthnSettings WebAuthn { get; set; } + ICommunicationSettings Communication { get; set; } } diff --git a/src/Core/Settings/ISsoCookieVendorSettings.cs b/src/Core/Settings/ISsoCookieVendorSettings.cs new file mode 100644 index 0000000000..a9f2169b13 --- /dev/null +++ b/src/Core/Settings/ISsoCookieVendorSettings.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Settings; + +public interface ISsoCookieVendorSettings +{ + string IdpLoginUrl { get; set; } + string CookieName { get; set; } + string CookieDomain { get; set; } +} diff --git a/src/Core/Tools/Entities/Send.cs b/src/Core/Tools/Entities/Send.cs index 52b439c41e..c4398e212c 100644 --- a/src/Core/Tools/Entities/Send.cs +++ b/src/Core/Tools/Entities/Send.cs @@ -81,6 +81,15 @@ public class Send : ITableObject [MaxLength(4000)] public string? Emails { get; set; } + /// + /// Comma-separated list of email **hashes** for OTP authentication. + /// + /// + /// This field is mutually exclusive with + /// + [MaxLength(4000)] + public string? EmailHashes { get; set; } + /// /// The send becomes unavailable to API callers when /// >= . diff --git a/src/Core/Tools/Enums/AuthType.cs b/src/Core/Tools/Enums/AuthType.cs index 814ebf69b8..4a31275b7a 100644 --- a/src/Core/Tools/Enums/AuthType.cs +++ b/src/Core/Tools/Enums/AuthType.cs @@ -1,11 +1,8 @@ -using System.Text.Json.Serialization; - -namespace Bit.Core.Tools.Enums; +namespace Bit.Core.Tools.Enums; /// /// Specifies the authentication method required to access a Send. /// -[JsonConverter(typeof(JsonStringEnumConverter))] public enum AuthType : byte { /// diff --git a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs index fa558f5963..9300e3c4bb 100644 --- a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs +++ b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs @@ -74,7 +74,7 @@ public class ImportCiphersCommand : IImportCiphersCommand if (cipher.UserId.HasValue && cipher.Favorite) { - cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":\"true\"}}"; + cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":true}}"; } } diff --git a/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs b/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs index 9ce477ed0c..769e9df713 100644 --- a/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs +++ b/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs @@ -44,7 +44,7 @@ public record ResourcePassword(string Hash) : SendAuthenticationMethod; /// /// Create a send claim by requesting a one time password (OTP) confirmation code. /// -/// -/// The list of email addresses permitted access to the send. +/// +/// The list of email address **hashes** permitted access to the send. /// -public record EmailOtp(string[] Emails) : SendAuthenticationMethod; +public record EmailOtp(string[] EmailHashes) : SendAuthenticationMethod; diff --git a/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs b/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs index fed7c9e8d4..a82c27d0c3 100644 --- a/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs +++ b/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs @@ -1,4 +1,5 @@ -using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Repositories; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; @@ -36,18 +37,25 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery SendAuthenticationMethod method = send switch { null => NEVER_AUTHENTICATE, - var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE, - var s when s.Emails is not null => emailOtp(s.Emails), - var s when s.Password is not null => new ResourcePassword(s.Password), + var s when s.Disabled => NEVER_AUTHENTICATE, + var s when s.AccessCount >= s.MaxAccessCount.GetValueOrDefault(int.MaxValue) => NEVER_AUTHENTICATE, + var s when s.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow => NEVER_AUTHENTICATE, + var s when s.DeletionDate <= DateTime.UtcNow => NEVER_AUTHENTICATE, + var s when s.AuthType == AuthType.Email && s.EmailHashes is not null => EmailOtp(s.EmailHashes), + var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password), _ => NOT_AUTHENTICATED }; return method; } - private EmailOtp emailOtp(string emails) + private static EmailOtp EmailOtp(string? emailHashes) { - var list = emails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (string.IsNullOrWhiteSpace(emailHashes)) + { + return new EmailOtp([]); + } + var list = emailHashes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); return new EmailOtp(list); } } diff --git a/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs b/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs index cb539429a5..29bd8f56f9 100644 --- a/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs +++ b/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs @@ -12,7 +12,6 @@ namespace Bit.Core.Tools.SendFeatures.Queries; public class SendOwnerQuery : ISendOwnerQuery { private readonly ISendRepository _repository; - private readonly IFeatureService _features; private readonly IUserService _users; /// @@ -24,10 +23,9 @@ public class SendOwnerQuery : ISendOwnerQuery /// /// Thrown when is . /// - public SendOwnerQuery(ISendRepository sendRepository, IFeatureService features, IUserService users) + public SendOwnerQuery(ISendRepository sendRepository, IUserService users) { _repository = sendRepository; - _features = features ?? throw new ArgumentNullException(nameof(features)); _users = users ?? throw new ArgumentNullException(nameof(users)); } @@ -51,16 +49,6 @@ public class SendOwnerQuery : ISendOwnerQuery var userId = _users.GetProperUserId(user) ?? throw new BadRequestException("invalid user."); var sends = await _repository.GetManyByUserIdAsync(userId); - var removeEmailOtp = !_features.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends); - if (removeEmailOtp) - { - // reify list to avoid invalidating the enumerator - foreach (var s in sends.Where(s => s.Emails != null).ToList()) - { - sends.Remove(s); - } - } - return sends; } } diff --git a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs index c545c8b35f..bd987bb396 100644 --- a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs +++ b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs @@ -6,6 +6,7 @@ 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.Services; +using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -27,6 +28,7 @@ public class SendValidationService : ISendValidationService private readonly GlobalSettings _globalSettings; private readonly ICurrentContext _currentContext; private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly IPricingClient _pricingClient; @@ -38,7 +40,7 @@ public class SendValidationService : ISendValidationService IUserService userService, IPolicyRequirementQuery policyRequirementQuery, GlobalSettings globalSettings, - + IPricingClient pricingClient, ICurrentContext currentContext) { _userRepository = userRepository; @@ -48,6 +50,7 @@ public class SendValidationService : ISendValidationService _userService = userService; _policyRequirementQuery = policyRequirementQuery; _globalSettings = globalSettings; + _pricingClient = pricingClient; _currentContext = currentContext; } @@ -123,10 +126,19 @@ public class SendValidationService : ISendValidationService } else { - // Users that get access to file storage/premium from their organization get the default - // 1 GB max storage. - short limit = _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1; - storageBytesRemaining = user.StorageBytesRemaining(limit); + // Users that get access to file storage/premium from their organization get storage + // based on the current premium plan from the pricing service + short provided; + if (_globalSettings.SelfHosted) + { + provided = Constants.SelfHostedMaxStorageGb; + } + else + { + var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); + provided = (short)premiumPlan.Storage.Provided; + } + storageBytesRemaining = user.StorageBytesRemaining(provided); } } else if (send.OrganizationId.HasValue) diff --git a/src/Core/Utilities/DomainNameAttribute.cs b/src/Core/Utilities/DomainNameAttribute.cs new file mode 100644 index 0000000000..9b571e96d7 --- /dev/null +++ b/src/Core/Utilities/DomainNameAttribute.cs @@ -0,0 +1,64 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; + +namespace Bit.Core.Utilities; + +/// +/// https://bitwarden.atlassian.net/browse/VULN-376 +/// Domain names are vulnerable to XSS attacks if not properly validated. +/// Domain names can contain letters, numbers, dots, and hyphens. +/// Domain names maybe internationalized (IDN) and contain unicode characters. +/// +public class DomainNameValidatorAttribute : ValidationAttribute +{ + // RFC 1123 compliant domain name regex + // - Allows alphanumeric characters and hyphens + // - Cannot start or end with a hyphen + // - Each label (part between dots) must be 1-63 characters + // - Total length should not exceed 253 characters + // - Supports internationalized domain names (IDN) - which is why this regex includes unicode ranges + private static readonly Regex _domainNameRegex = new( + @"^(?:[a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF](?:[a-zA-Z0-9\-\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{0,61}[a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])?\.)*[a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF](?:[a-zA-Z0-9\-\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{0,61}[a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])?$", + RegexOptions.Compiled | RegexOptions.IgnoreCase + ); + + public DomainNameValidatorAttribute() + : base("The {0} field is not a valid domain name.") + { } + + public override bool IsValid(object? value) + { + if (value == null) + { + return true; // Use [Required] for null checks + } + + var domainName = value.ToString(); + + if (string.IsNullOrWhiteSpace(domainName)) + { + return false; + } + + // Reject if contains any whitespace (including leading/trailing spaces, tabs, newlines) + if (domainName.Any(char.IsWhiteSpace)) + { + return false; + } + + // Check length constraints + if (domainName.Length > 253) + { + return false; + } + + // Check for control characters or other dangerous characters + if (domainName.Any(c => char.IsControl(c) || c == '<' || c == '>' || c == '"' || c == '\'' || c == '&')) + { + return false; + } + + // Validate against domain name regex + return _domainNameRegex.IsMatch(domainName); + } +} diff --git a/src/Core/Utilities/EnumMemberJsonConverter.cs b/src/Core/Utilities/EnumMemberJsonConverter.cs new file mode 100644 index 0000000000..63bebf9cca --- /dev/null +++ b/src/Core/Utilities/EnumMemberJsonConverter.cs @@ -0,0 +1,52 @@ +using System.Reflection; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Bit.Core.Utilities; + +/// +/// A custom JSON converter for enum types that respects the when serializing and deserializing. +/// +/// The enum type to convert. Must be a struct and implement Enum. +/// +/// This converter builds lookup dictionaries at initialization to efficiently map between enum values and their +/// string representations. If an enum value has an , the attribute's Value +/// property is used as the JSON string; otherwise, the enum's ToString() value is used. +/// +public class EnumMemberJsonConverter : JsonConverter where T : struct, Enum +{ + private readonly Dictionary _enumToString = new(); + private readonly Dictionary _stringToEnum = new(); + + public EnumMemberJsonConverter() + { + var type = typeof(T); + var values = Enum.GetValues(); + + foreach (var value in values) + { + var fieldInfo = type.GetField(value.ToString()); + var attribute = fieldInfo?.GetCustomAttribute(); + + var stringValue = attribute?.Value ?? value.ToString(); + _enumToString[value] = stringValue; + _stringToEnum[stringValue] = value; + } + } + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var stringValue = reader.GetString(); + + if (!string.IsNullOrEmpty(stringValue) && _stringToEnum.TryGetValue(stringValue, out var enumValue)) + { + return enumValue; + } + + throw new JsonException($"Unable to convert '{stringValue}' to {typeof(T).Name}"); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + => writer.WriteStringValue(_enumToString[value]); +} diff --git a/src/Core/Utilities/LoggerFactoryExtensions.cs b/src/Core/Utilities/LoggerFactoryExtensions.cs index b950e30d5d..f3330f0792 100644 --- a/src/Core/Utilities/LoggerFactoryExtensions.cs +++ b/src/Core/Utilities/LoggerFactoryExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Hosting; +using System.Globalization; +using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -8,7 +9,7 @@ namespace Bit.Core.Utilities; public static class LoggerFactoryExtensions { /// - /// + /// /// /// /// @@ -21,10 +22,12 @@ public static class LoggerFactoryExtensions return; } + IConfiguration loggingConfiguration; + // If they have begun using the new settings location, use that if (!string.IsNullOrEmpty(context.Configuration["Logging:PathFormat"])) { - logging.AddFile(context.Configuration.GetSection("Logging")); + loggingConfiguration = context.Configuration.GetSection("Logging"); } else { @@ -40,28 +43,35 @@ public static class LoggerFactoryExtensions var projectName = loggingOptions.ProjectName ?? context.HostingEnvironment.ApplicationName; + string pathFormat; + if (loggingOptions.LogRollBySizeLimit.HasValue) { - var pathFormat = loggingOptions.LogDirectoryByProject + pathFormat = loggingOptions.LogDirectoryByProject ? Path.Combine(loggingOptions.LogDirectory, projectName, "log.txt") : Path.Combine(loggingOptions.LogDirectory, $"{projectName.ToLowerInvariant()}.log"); - - logging.AddFile( - pathFormat: pathFormat, - fileSizeLimitBytes: loggingOptions.LogRollBySizeLimit.Value - ); } else { - var pathFormat = loggingOptions.LogDirectoryByProject + pathFormat = loggingOptions.LogDirectoryByProject ? Path.Combine(loggingOptions.LogDirectory, projectName, "{Date}.txt") : Path.Combine(loggingOptions.LogDirectory, $"{projectName.ToLowerInvariant()}_{{Date}}.log"); - - logging.AddFile( - pathFormat: pathFormat - ); } + + // We want to rely on Serilog using the configuration section to have customization of the log levels + // so we make a custom configuration source for them based on the legacy values and allow overrides from + // the new location. + loggingConfiguration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + {"PathFormat", pathFormat}, + {"FileSizeLimitBytes", loggingOptions.LogRollBySizeLimit?.ToString(CultureInfo.InvariantCulture)} + }) + .AddConfiguration(context.Configuration.GetSection("Logging")) + .Build(); } + + logging.AddFile(loggingConfiguration); }); } diff --git a/src/Core/Vault/Commands/ArchiveCiphersCommand.cs b/src/Core/Vault/Commands/ArchiveCiphersCommand.cs index 6c8e0fcf75..0a84dd7fa7 100644 --- a/src/Core/Vault/Commands/ArchiveCiphersCommand.cs +++ b/src/Core/Vault/Commands/ArchiveCiphersCommand.cs @@ -37,7 +37,7 @@ public class ArchiveCiphersCommand : IArchiveCiphersCommand } var archivingCiphers = ciphers - .Where(c => cipherIdsSet.Contains(c.Id) && c is { Edit: true, OrganizationId: null, ArchivedDate: null }) + .Where(c => cipherIdsSet.Contains(c.Id) && c is { ArchivedDate: null }) .ToList(); var revisionDate = await _cipherRepository.ArchiveAsync(archivingCiphers.Select(c => c.Id), archivingUserId); diff --git a/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs b/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs index 83dcbab4e1..3a513db3f3 100644 --- a/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs +++ b/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs @@ -37,7 +37,7 @@ public class UnarchiveCiphersCommand : IUnarchiveCiphersCommand } var unarchivingCiphers = ciphers - .Where(c => cipherIdsSet.Contains(c.Id) && c is { Edit: true, ArchivedDate: not null }) + .Where(c => cipherIdsSet.Contains(c.Id) && c is { ArchivedDate: not null }) .ToList(); var revisionDate = diff --git a/src/Core/Vault/Entities/Cipher.cs b/src/Core/Vault/Entities/Cipher.cs index f6afc090bb..4dec4ace07 100644 --- a/src/Core/Vault/Entities/Cipher.cs +++ b/src/Core/Vault/Entities/Cipher.cs @@ -25,7 +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 string Archives { get; set; } public void SetNewId() { diff --git a/src/Core/Vault/Models/Data/CipherDetails.cs b/src/Core/Vault/Models/Data/CipherDetails.cs index e0ece1efec..56f5aa39bb 100644 --- a/src/Core/Vault/Models/Data/CipherDetails.cs +++ b/src/Core/Vault/Models/Data/CipherDetails.cs @@ -9,7 +9,8 @@ public class CipherDetails : CipherOrganizationDetails public bool Edit { get; set; } public bool ViewPassword { get; set; } public bool Manage { get; set; } - + // Per-user archived date from Archives JSON. + public DateTime? ArchivedDate { get; set; } public CipherDetails() { } public CipherDetails(CipherOrganizationDetails cipher) @@ -51,6 +52,7 @@ public class CipherDetailsWithCollections : CipherDetails Reprompt = cipher.Reprompt; Key = cipher.Key; FolderId = cipher.FolderId; + ArchivedDate = cipher.ArchivedDate; Favorite = cipher.Favorite; Edit = cipher.Edit; ViewPassword = cipher.ViewPassword; diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index fa2cfbb209..140399a37a 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Pricing; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Platform.Push; @@ -46,6 +47,7 @@ public class CipherService : ICipherService private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IApplicationCacheService _applicationCacheService; private readonly IFeatureService _featureService; + private readonly IPricingClient _pricingClient; public CipherService( ICipherRepository cipherRepository, @@ -65,7 +67,8 @@ public class CipherService : ICipherService IGetCipherPermissionsForUserQuery getCipherPermissionsForUserQuery, IPolicyRequirementQuery policyRequirementQuery, IApplicationCacheService applicationCacheService, - IFeatureService featureService) + IFeatureService featureService, + IPricingClient pricingClient) { _cipherRepository = cipherRepository; _folderRepository = folderRepository; @@ -85,6 +88,7 @@ public class CipherService : ICipherService _policyRequirementQuery = policyRequirementQuery; _applicationCacheService = applicationCacheService; _featureService = featureService; + _pricingClient = pricingClient; } public async Task SaveAsync(Cipher cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, @@ -943,10 +947,19 @@ public class CipherService : ICipherService } else { - // Users that get access to file storage/premium from their organization get the default - // 1 GB max storage. - storageBytesRemaining = user.StorageBytesRemaining( - _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1); + // Users that get access to file storage/premium from their organization get storage + // based on the current premium plan from the pricing service + short provided; + if (_globalSettings.SelfHosted) + { + provided = Constants.SelfHostedMaxStorageGb; + } + else + { + var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); + provided = (short)premiumPlan.Storage.Provided; + } + storageBytesRemaining = user.StorageBytesRemaining(provided); } } else if (cipher.OrganizationId.HasValue) diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index 75301cf08c..d97d65c2ed 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -36,6 +36,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); diff --git a/src/Events/appsettings.Production.json b/src/Events/appsettings.Production.json index 010f02f8cd..9a10621264 100644 --- a/src/Events/appsettings.Production.json +++ b/src/Events/appsettings.Production.json @@ -17,11 +17,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/EventsProcessor/Startup.cs b/src/EventsProcessor/Startup.cs index 888dda43a1..78f99058ad 100644 --- a/src/EventsProcessor/Startup.cs +++ b/src/EventsProcessor/Startup.cs @@ -1,7 +1,6 @@ using System.Globalization; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; -using Microsoft.IdentityModel.Logging; namespace Bit.EventsProcessor; @@ -30,6 +29,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Add event integration services services.AddDistributedCache(globalSettings); @@ -39,7 +39,6 @@ public class Startup public void Configure(IApplicationBuilder app) { - IdentityModelEventSource.ShowPII = true; // Add general security headers app.UseMiddleware(); app.UseRouting(); diff --git a/src/EventsProcessor/appsettings.Production.json b/src/EventsProcessor/appsettings.Production.json index 1cce4a9ed3..d57bf98b55 100644 --- a/src/EventsProcessor/appsettings.Production.json +++ b/src/EventsProcessor/appsettings.Production.json @@ -1,10 +1,8 @@ { "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Icons/Icons.csproj b/src/Icons/Icons.csproj index 455c8b3155..9dc39eab1e 100644 --- a/src/Icons/Icons.csproj +++ b/src/Icons/Icons.csproj @@ -3,11 +3,13 @@ bitwarden-Icons false + + $(WarningsNotAsErrors);CA1304;CA1305 - + diff --git a/src/Icons/appsettings.Production.json b/src/Icons/appsettings.Production.json index 828e8c61cc..19d21f7260 100644 --- a/src/Icons/appsettings.Production.json +++ b/src/Icons/appsettings.Production.json @@ -17,11 +17,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Identity/Identity.csproj b/src/Identity/Identity.csproj index bf5ab82166..db49f8c856 100644 --- a/src/Identity/Identity.csproj +++ b/src/Identity/Identity.csproj @@ -3,6 +3,8 @@ bitwarden-Identity false + + $(WarningsNotAsErrors);CA1305 diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 6ab5236323..f343703665 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -4,7 +4,6 @@ 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; @@ -265,56 +264,14 @@ public abstract class BaseRequestValidator where T : class private async Task ValidateSsoAsync(T context, ValidatedTokenRequest request, CustomValidatorRequestContext validatorContext) { - // TODO: Clean up Feature Flag: Remove this if block: PM-28281 - if (!_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired)) + var ssoValid = await _ssoRequestValidator.ValidateAsync(validatorContext.User, request, validatorContext); + if (ssoValid) { - validatorContext.SsoRequired = await RequireSsoLoginAsync(validatorContext.User, request.GrantType); - if (!validatorContext.SsoRequired) - { - return true; - } - - // Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are - // presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and - // review their new recovery token if desired. - // SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery. - // As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been - // evaluated, and recovery will have been performed if requested. - // We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect - // to /login. - if (validatorContext.TwoFactorRequired && - validatorContext.TwoFactorRecoveryRequested) - { - SetSsoResult(context, - new Dictionary - { - { - "ErrorModel", - new ErrorResponseModel( - "Two-factor recovery has been performed. SSO authentication is required.") - } - }); - return false; - } - - SetSsoResult(context, - new Dictionary - { - { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } - }); - return false; + return true; } - else - { - var ssoValid = await _ssoRequestValidator.ValidateAsync(validatorContext.User, request, validatorContext); - if (ssoValid) - { - return true; - } - SetValidationErrorResult(context, validatorContext); - return ssoValid; - } + SetValidationErrorResult(context, validatorContext); + return ssoValid; } /// @@ -553,9 +510,6 @@ public abstract class BaseRequestValidator where T : class [Obsolete("Consider using SetValidationErrorResult instead.")] protected abstract void SetTwoFactorResult(T context, Dictionary customResponse); - [Obsolete("Consider using SetValidationErrorResult instead.")] - protected abstract void SetSsoResult(T context, Dictionary customResponse); - [Obsolete("Consider using SetValidationErrorResult instead.")] protected abstract void SetErrorResult(T context, Dictionary customResponse); @@ -572,41 +526,6 @@ public abstract class BaseRequestValidator where T : class protected abstract ClaimsPrincipal GetSubject(T context); - /// - /// Check if the user is required to authenticate via SSO. If the user requires SSO, but they are - /// logging in using an API Key (client_credentials) then they are allowed to bypass the SSO requirement. - /// If the GrantType is authorization_code or client_credentials we know the user is trying to login - /// using the SSO flow so they are allowed to continue. - /// - /// user trying to login - /// magic string identifying the grant type requested - /// true if sso required; false if not required or already in process - [Obsolete( - "This method is deprecated and will be removed in future versions, PM-28281. Please use the SsoRequestValidator scheme instead.")] - private async Task RequireSsoLoginAsync(User user, string grantType) - { - if (grantType == "authorization_code" || grantType == "client_credentials") - { - // Already using SSO to authenticate, or logging-in via api key to skip SSO requirement - // allow to authenticate successfully - return false; - } - - // Check if user belongs to any organization with an active SSO policy - var ssoRequired = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) - ? (await PolicyRequirementQuery.GetAsync(user.Id)) - .SsoRequired - : await PolicyService.AnyPoliciesApplicableToUserAsync( - user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); - if (ssoRequired) - { - return true; - } - - // Default - SSO is not required - return false; - } - private async Task ResetFailedAuthDetailsAsync(User user) { // Early escape if db hit not necessary diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index fbf6f3796d..292cc48438 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -193,17 +193,6 @@ public class CustomTokenRequestValidator : BaseRequestValidator customResponse) - { - Debug.Assert(context.Result is not null); - context.Result.Error = "invalid_grant"; - context.Result.ErrorDescription = "Sso authentication required."; - context.Result.IsError = true; - context.Result.CustomResponse = customResponse; - } - [Obsolete("Consider using SetGrantValidationErrorResult instead.")] protected override void SetErrorResult(CustomTokenRequestValidationContext context, Dictionary customResponse) diff --git a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs index 53d28c13cc..7f765a4923 100644 --- a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs @@ -153,14 +153,6 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator customResponse) - { - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.", - customResponse); - } - [Obsolete("Consider using SetGrantValidationErrorResult instead.")] protected override void SetErrorResult(ResourceOwnerPasswordValidationContext context, Dictionary customResponse) diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs index 34a7a6f6e7..f20fdb6f07 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs @@ -1,4 +1,6 @@ using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; using Bit.Core; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; @@ -40,8 +42,10 @@ public class SendEmailOtpRequestValidator( return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired); } - // email must be in the list of emails in the EmailOtp array - if (!authMethod.Emails.Contains(email)) + // email hash must be in the list of email hashes in the EmailOtp array + byte[] hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(email)); + string hashEmailHex = Convert.ToHexString(hashBytes).ToUpperInvariant(); + if (!authMethod.EmailHashes.Contains(hashEmailHex)) { return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid); } diff --git a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs index 9a23db1248..6d26476c4c 100644 --- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs @@ -144,14 +144,6 @@ public class WebAuthnGrantValidator : BaseRequestValidator customResponse) - { - context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.", - customResponse); - } - [Obsolete("Consider using SetValidationErrorResult instead.")] protected override void SetErrorResult(ExtensionGrantValidationContext context, Dictionary customResponse) { diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs index fddc77c806..003e9a032e 100644 --- a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs +++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs @@ -1,5 +1,4 @@ -using Bit.Core; -using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Utilities; @@ -8,7 +7,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Api.Response; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Identity.Utilities; @@ -26,8 +24,6 @@ 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 = null!; private SsoConfig? _ssoConfig; @@ -37,15 +33,13 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder ICurrentContext currentContext, IDeviceRepository deviceRepository, IOrganizationUserRepository organizationUserRepository, - ILoginApprovingClientTypes loginApprovingClientTypes, - IFeatureService featureService + ILoginApprovingClientTypes loginApprovingClientTypes ) { _currentContext = currentContext; _deviceRepository = deviceRepository; _organizationUserRepository = organizationUserRepository; _loginApprovingClientTypes = loginApprovingClientTypes; - _featureService = featureService; } public IUserDecryptionOptionsBuilder ForUser(User user) @@ -70,8 +64,12 @@ 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, + credential.CredentialId, + [] // Stored credentials currently lack Transports, just send an empty array for now + ); } return this; @@ -145,34 +143,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder // 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; - if (_featureService.IsEnabled(FeatureFlagKeys.PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword)) - { - hasManageResetPasswordPermission = await EvaluateHasManageResetPasswordPermission(); - } - 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 - - // 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); - } + var hasManageResetPasswordPermission = await EvaluateHasManageResetPasswordPermission(); // 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); @@ -186,10 +157,10 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder encryptedUserKey); return; + /// Determine if the user has manage reset password permission, + /// as post-SSO logic requires it for forcing users with this permission to set a password. 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; diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index 5dc443a73c..c6d21b59ad 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -14,7 +14,6 @@ using Bit.SharedWeb.Swagger; using Bit.SharedWeb.Utilities; using Duende.IdentityServer.Services; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.IdentityModel.Logging; using Microsoft.OpenApi.Models; namespace Bit.Identity; @@ -49,6 +48,7 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); + services.AddTestPlayIdTracking(globalSettings); // Context services.AddScoped(); @@ -169,16 +169,14 @@ public class Startup public void Configure( IApplicationBuilder app, - IWebHostEnvironment env, + IWebHostEnvironment environment, GlobalSettings globalSettings, ILogger logger) { - IdentityModelEventSource.ShowPII = true; - // Add general security headers app.UseMiddleware(); - if (!env.IsDevelopment()) + if (!environment.IsDevelopment()) { var uri = new Uri(globalSettings.BaseServiceUri.Identity); app.Use(async (ctx, next) => @@ -195,7 +193,7 @@ public class Startup } // Default Middleware - app.UseDefaultMiddleware(env, globalSettings); + app.UseDefaultMiddleware(environment, globalSettings); if (!globalSettings.SelfHosted) { @@ -203,7 +201,7 @@ public class Startup app.UseMiddleware(); } - if (env.IsDevelopment()) + if (environment.IsDevelopment()) { app.UseSwagger(); app.UseDeveloperExceptionPage(); diff --git a/src/Identity/appsettings.Production.json b/src/Identity/appsettings.Production.json index 4897a7d8b1..14471b5fb6 100644 --- a/src/Identity/appsettings.Production.json +++ b/src/Identity/appsettings.Production.json @@ -20,11 +20,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs index 2be33e8846..d6553926ab 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs @@ -218,8 +218,8 @@ public static class BulkResourceCreationService ciphersTable.Columns.Add(revisionDateColumn); var deletedDateColumn = new DataColumn(nameof(c.DeletedDate), typeof(DateTime)); ciphersTable.Columns.Add(deletedDateColumn); - var archivedDateColumn = new DataColumn(nameof(c.ArchivedDate), typeof(DateTime)); - ciphersTable.Columns.Add(archivedDateColumn); + var archivesColumn = new DataColumn(nameof(c.Archives), typeof(string)); + ciphersTable.Columns.Add(archivesColumn); var repromptColumn = new DataColumn(nameof(c.Reprompt), typeof(short)); ciphersTable.Columns.Add(repromptColumn); var keyColummn = new DataColumn(nameof(c.Key), typeof(string)); @@ -249,7 +249,7 @@ public static class BulkResourceCreationService row[creationDateColumn] = cipher.CreationDate; row[revisionDateColumn] = cipher.RevisionDate; row[deletedDateColumn] = cipher.DeletedDate.HasValue ? (object)cipher.DeletedDate : DBNull.Value; - row[archivedDateColumn] = cipher.ArchivedDate.HasValue ? cipher.ArchivedDate : DBNull.Value; + row[archivesColumn] = cipher.Archives; row[repromptColumn] = cipher.Reprompt.HasValue ? cipher.Reprompt.Value : DBNull.Value; row[keyColummn] = cipher.Key; diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index 96ddc8c7da..434edb7676 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -17,7 +17,7 @@ namespace Bit.Infrastructure.Dapper.Repositories; public class OrganizationRepository : Repository, IOrganizationRepository { - private readonly ILogger _logger; + protected readonly ILogger _logger; public OrganizationRepository( GlobalSettings globalSettings, diff --git a/src/Infrastructure.Dapper/DapperHelpers.cs b/src/Infrastructure.Dapper/DapperHelpers.cs index 9a119e1e32..4384a6f752 100644 --- a/src/Infrastructure.Dapper/DapperHelpers.cs +++ b/src/Infrastructure.Dapper/DapperHelpers.cs @@ -160,6 +160,21 @@ public static class DapperHelpers return ids.ToArrayTVP("GuidId"); } + public static DataTable ToTwoGuidIdArrayTVP(this IEnumerable<(Guid id1, Guid id2)> values) + { + var table = new DataTable(); + table.SetTypeName("[dbo].[TwoGuidIdArray]"); + table.Columns.Add("Id1", typeof(Guid)); + table.Columns.Add("Id2", typeof(Guid)); + + foreach (var value in values) + { + table.Rows.Add(value.id1, value.id2); + } + + return table; + } + public static DataTable ToArrayTVP(this IEnumerable values, string columnName) { var table = new DataTable(); diff --git a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs index e3ee82270f..dcb0dc1306 100644 --- a/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs +++ b/src/Infrastructure.Dapper/DapperServiceCollectionExtensions.cs @@ -51,6 +51,7 @@ public static class DapperServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj index 8feb455feb..d87bdc33a9 100644 --- a/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj +++ b/src/Infrastructure.Dapper/Infrastructure.Dapper.csproj @@ -1,5 +1,10 @@ + + + $(WarningsNotAsErrors);CA1305 + + diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index 9985b41d56..1531703427 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -1,6 +1,7 @@ using System.Data; using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using Bit.Core.AdminConsole.OrganizationFeatures.Collections; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -360,7 +361,45 @@ public class CollectionRepository : Repository, ICollectionRep } } - public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) + public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) + { + organizationUserIds = organizationUserIds.ToList(); + if (!organizationUserIds.Any()) + { + return; + } + + var organizationUserCollectionIds = organizationUserIds + .Select(ou => (ou, CoreHelpers.GenerateComb())) + .ToTwoGuidIdArrayTVP(); + + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + await using var transaction = connection.BeginTransaction(); + + try + { + await connection.ExecuteAsync( + "[dbo].[Collection_CreateDefaultCollections]", + new + { + OrganizationId = organizationId, + DefaultCollectionName = defaultCollectionName, + OrganizationUserCollectionIds = organizationUserCollectionIds + }, + commandType: CommandType.StoredProcedure, + transaction: transaction); + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + + public async Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) { organizationUserIds = organizationUserIds.ToList(); if (!organizationUserIds.Any()) @@ -377,7 +416,8 @@ public class CollectionRepository : Repository, ICollectionRep var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection); - var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName); + var (collections, collectionUsers) = + CollectionUtils.BuildDefaultUserCollections(organizationId, missingDefaultCollectionUserIds, defaultCollectionName); if (!collectionUsers.Any() || !collections.Any()) { @@ -387,11 +427,11 @@ public class CollectionRepository : Repository, ICollectionRep await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections); await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers); - transaction.Commit(); + await transaction.CommitAsync(); } catch { - transaction.Rollback(); + await transaction.RollbackAsync(); throw; } } @@ -421,40 +461,6 @@ public class CollectionRepository : Repository, ICollectionRep 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 { public CollectionWithGroupsAndUsers() { } diff --git a/src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs b/src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs new file mode 100644 index 0000000000..1fa8e88ce8 --- /dev/null +++ b/src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs @@ -0,0 +1,45 @@ +using System.Data; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Dapper; +using Microsoft.Data.SqlClient; + +#nullable enable + +namespace Bit.Infrastructure.Dapper.Repositories; + +public class PlayItemRepository : Repository, IPlayItemRepository +{ + public PlayItemRepository(GlobalSettings globalSettings) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + { } + + public PlayItemRepository(string connectionString, string readOnlyConnectionString) + : base(connectionString, readOnlyConnectionString) + { } + + public async Task> GetByPlayIdAsync(string playId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[PlayItem_ReadByPlayId]", + new { PlayId = playId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + + public async Task DeleteByPlayIdAsync(string playId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync( + "[dbo].[PlayItem_DeleteByPlayId]", + new { PlayId = playId }, + commandType: CommandType.StoredProcedure); + } + } +} diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 571319e4c7..920145f2f2 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -428,6 +428,55 @@ public class UserRepository : Repository, IUserRepository }; } + public UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData, + string serverSideHashedMasterPasswordAuthenticationHash, string? masterPasswordHint) + { + return async (connection, transaction) => + { + var timestamp = DateTime.UtcNow; + + await connection!.ExecuteAsync( + "[dbo].[User_UpdateMasterPassword]", + new + { + Id = userId, + MasterPassword = serverSideHashedMasterPasswordAuthenticationHash, + MasterPasswordHint = masterPasswordHint, + Key = masterPasswordUnlockData.MasterKeyWrappedUserKey, + Kdf = masterPasswordUnlockData.Kdf.KdfType, + KdfIterations = masterPasswordUnlockData.Kdf.Iterations, + KdfMemory = masterPasswordUnlockData.Kdf.Memory, + KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism, + RevisionDate = timestamp, + AccountRevisionDate = timestamp + }, + transaction: transaction, + commandType: CommandType.StoredProcedure); + }; + } + + public async Task UpdateUserDataAsync(IEnumerable updateUserDataActions) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using var transaction = connection.BeginTransaction(); + try + { + foreach (var action in updateUserDataActions) + { + await action(connection, transaction); + } + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + private async Task ProtectDataAndSaveAsync(User user, Func saveTask) { if (user == null) diff --git a/src/Infrastructure.Dapper/Tools/Repositories/SendRepository.cs b/src/Infrastructure.Dapper/Tools/Repositories/SendRepository.cs index 81a94f0f7c..4c5d70340f 100644 --- a/src/Infrastructure.Dapper/Tools/Repositories/SendRepository.cs +++ b/src/Infrastructure.Dapper/Tools/Repositories/SendRepository.cs @@ -1,6 +1,7 @@ #nullable enable using System.Data; +using Bit.Core; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Settings; using Bit.Core.Tools.Entities; @@ -8,6 +9,7 @@ using Bit.Core.Tools.Repositories; using Bit.Infrastructure.Dapper.Repositories; using Bit.Infrastructure.Dapper.Tools.Helpers; using Dapper; +using Microsoft.AspNetCore.DataProtection; using Microsoft.Data.SqlClient; namespace Bit.Infrastructure.Dapper.Tools.Repositories; @@ -15,13 +17,24 @@ namespace Bit.Infrastructure.Dapper.Tools.Repositories; /// public class SendRepository : Repository, ISendRepository { - public SendRepository(GlobalSettings globalSettings) - : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) + private readonly IDataProtector _dataProtector; + + public SendRepository(GlobalSettings globalSettings, IDataProtectionProvider dataProtectionProvider) + : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString, dataProtectionProvider) { } - public SendRepository(string connectionString, string readOnlyConnectionString) + public SendRepository(string connectionString, string readOnlyConnectionString, IDataProtectionProvider dataProtectionProvider) : base(connectionString, readOnlyConnectionString) - { } + { + _dataProtector = dataProtectionProvider.CreateProtector(Constants.DatabaseFieldProtectorPurpose); + } + + public override async Task GetByIdAsync(Guid id) + { + var send = await base.GetByIdAsync(id); + UnprotectData(send); + return send; + } /// public async Task> GetManyByUserIdAsync(Guid userId) @@ -33,7 +46,9 @@ public class SendRepository : Repository, ISendRepository new { UserId = userId }, commandType: CommandType.StoredProcedure); - return results.ToList(); + var sends = results.ToList(); + UnprotectData(sends); + return sends; } } @@ -47,15 +62,35 @@ public class SendRepository : Repository, ISendRepository new { DeletionDate = deletionDateBefore }, commandType: CommandType.StoredProcedure); - return results.ToList(); + var sends = results.ToList(); + UnprotectData(sends); + return sends; } } + public override async Task CreateAsync(Send send) + { + await ProtectDataAndSaveAsync(send, async () => await base.CreateAsync(send)); + return send; + } + + public override async Task ReplaceAsync(Send send) + { + await ProtectDataAndSaveAsync(send, async () => await base.ReplaceAsync(send)); + } + /// public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, IEnumerable sends) { return async (connection, transaction) => { + // Protect all sends before bulk update + var sendsList = sends.ToList(); + foreach (var send in sendsList) + { + ProtectData(send); + } + // Create temp table var sqlCreateTemp = @" SELECT TOP 0 * @@ -71,7 +106,7 @@ public class SendRepository : Repository, ISendRepository using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction)) { bulkCopy.DestinationTableName = "#TempSend"; - var sendsTable = sends.ToDataTable(); + var sendsTable = sendsList.ToDataTable(); foreach (DataColumn col in sendsTable.Columns) { bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); @@ -101,6 +136,69 @@ public class SendRepository : Repository, ISendRepository cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId; cmd.ExecuteNonQuery(); } + + // Unprotect after save + foreach (var send in sendsList) + { + UnprotectData(send); + } }; } + + private async Task ProtectDataAndSaveAsync(Send send, Func saveTask) + { + if (send == null) + { + await saveTask(); + return; + } + + // Capture original value + var originalEmailHashes = send.EmailHashes; + + // Protect value + ProtectData(send); + + // Save + await saveTask(); + + // Restore original value + send.EmailHashes = originalEmailHashes; + } + + private void ProtectData(Send send) + { + if (!send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false) + { + send.EmailHashes = string.Concat(Constants.DatabaseFieldProtectedPrefix, + _dataProtector.Protect(send.EmailHashes!)); + } + } + + private void UnprotectData(Send? send) + { + if (send == null) + { + return; + } + + if (send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false) + { + send.EmailHashes = _dataProtector.Unprotect( + send.EmailHashes.Substring(Constants.DatabaseFieldProtectedPrefix.Length)); + } + } + + private void UnprotectData(IEnumerable sends) + { + if (sends == null) + { + return; + } + + foreach (var send in sends) + { + UnprotectData(send); + } + } } diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index 48232ef484..ecf6d8e4e7 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -248,7 +248,7 @@ public class CipherRepository : Repository, ICipherRepository new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId }, commandType: CommandType.StoredProcedure); - return results; + return DateTime.SpecifyKind(results, DateTimeKind.Utc); } } @@ -595,7 +595,7 @@ public class CipherRepository : Repository, ICipherRepository new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId }, commandType: CommandType.StoredProcedure); - return results; + return DateTime.SpecifyKind(results, DateTimeKind.Utc); } } @@ -608,7 +608,7 @@ public class CipherRepository : Repository, ICipherRepository new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId }, commandType: CommandType.StoredProcedure); - return results; + return DateTime.SpecifyKind(results, DateTimeKind.Utc); } } @@ -621,7 +621,7 @@ public class CipherRepository : Repository, ICipherRepository new { Ids = ids.ToGuidIdArrayTVP(), OrganizationId = organizationId }, commandType: CommandType.StoredProcedure); - return results; + return DateTime.SpecifyKind(results, DateTimeKind.Utc); } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index f2da58a1dd..93c8cd304c 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -20,7 +20,7 @@ namespace Bit.Infrastructure.EntityFramework.Repositories; public class OrganizationRepository : Repository, IOrganizationRepository { - private readonly ILogger _logger; + protected readonly ILogger _logger; public OrganizationRepository( IServiceScopeFactory serviceScopeFactory, @@ -114,6 +114,7 @@ public class OrganizationRepository : Repository od.OrganizationId == _organizationId && od.VerifiedDate != null && diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs index 9523b4afaa..16c8bf078f 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/ProviderUserOrganizationDetailsViewQuery.cs @@ -61,6 +61,7 @@ public class ProviderUserOrganizationDetailsViewQuery : IQuery +{ + public void Configure(EntityTypeBuilder builder) + { + builder + .Property(pd => pd.Id) + .ValueGeneratedNever(); + + builder + .HasIndex(pd => pd.PlayId) + .IsClustered(false); + + builder + .HasIndex(pd => pd.UserId) + .IsClustered(false); + + builder + .HasIndex(pd => pd.OrganizationId) + .IsClustered(false); + + builder + .HasOne(pd => pd.User) + .WithMany() + .HasForeignKey(pd => pd.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasOne(pd => pd.Organization) + .WithMany() + .HasForeignKey(pd => pd.OrganizationId) + .OnDelete(DeleteBehavior.Cascade); + + builder + .ToTable(nameof(PlayItem)) + .HasCheckConstraint( + "CK_PlayItem_UserOrOrganization", + "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)" + ); + } +} diff --git a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs index 3c35df2a82..320cb9436d 100644 --- a/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs +++ b/src/Infrastructure.EntityFramework/EntityFrameworkServiceCollectionExtensions.cs @@ -88,6 +88,7 @@ public static class EntityFrameworkServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj b/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj index 9814eef2aa..180bcd7705 100644 --- a/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj +++ b/src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj @@ -1,4 +1,9 @@ + + + $(WarningsNotAsErrors);CA1304;CA1305 + + diff --git a/src/Infrastructure.EntityFramework/Models/PlayItem.cs b/src/Infrastructure.EntityFramework/Models/PlayItem.cs new file mode 100644 index 0000000000..3aafebd555 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Models/PlayItem.cs @@ -0,0 +1,19 @@ +#nullable enable + +using AutoMapper; + +namespace Bit.Infrastructure.EntityFramework.Models; + +public class PlayItem : Core.Entities.PlayItem +{ + public virtual User? User { get; set; } + public virtual AdminConsole.Models.Organization? Organization { get; set; } +} + +public class PlayItemMapperProfile : Profile +{ + public PlayItemMapperProfile() + { + CreateMap().ReverseMap(); + } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 5aa156d1f8..74150246b1 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -1,11 +1,10 @@ using AutoMapper; +using Bit.Core.AdminConsole.OrganizationFeatures.Collections; 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; @@ -794,7 +793,7 @@ public class CollectionRepository : Repository organizationUserIds, string defaultCollectionName) + public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) { organizationUserIds = organizationUserIds.ToList(); if (!organizationUserIds.Any()) @@ -808,15 +807,15 @@ public class CollectionRepository : Repository>(collections)); + await dbContext.CollectionUsers.AddRangeAsync(Mapper.Map>(collectionUsers)); await dbContext.SaveChangesAsync(); } @@ -844,37 +843,7 @@ public class CollectionRepository : Repository 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 Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds, + string defaultCollectionName) => + CreateDefaultCollectionsAsync(organizationId, organizationUserIds, defaultCollectionName); } diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs index b748a26db2..3f638f88e5 100644 --- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs +++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs @@ -17,8 +17,6 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using DP = Microsoft.AspNetCore.DataProtection; -#nullable enable - namespace Bit.Infrastructure.EntityFramework.Repositories; public class DatabaseContext : DbContext @@ -57,6 +55,7 @@ public class DatabaseContext : DbContext public DbSet OrganizationApiKeys { get; set; } public DbSet OrganizationSponsorships { get; set; } public DbSet OrganizationConnections { get; set; } + public DbSet PlayItem { get; set; } public DbSet OrganizationIntegrations { get; set; } public DbSet OrganizationIntegrationConfigurations { get; set; } public DbSet OrganizationUsers { get; set; } @@ -120,6 +119,7 @@ public class DatabaseContext : DbContext var eOrganizationDomain = builder.Entity(); var aWebAuthnCredential = builder.Entity(); var eOrganizationMemberBaseDetail = builder.Entity(); + var eSend = builder.Entity(); // Shadow property configurations go here @@ -149,6 +149,7 @@ public class DatabaseContext : DbContext var dataProtectionConverter = new DataProtectionConverter(dataProtector); eUser.Property(c => c.Key).HasConversion(dataProtectionConverter); eUser.Property(c => c.MasterPassword).HasConversion(dataProtectionConverter); + eSend.Property(c => c.EmailHashes).HasConversion(dataProtectionConverter); if (Database.IsNpgsql()) { diff --git a/src/Infrastructure.EntityFramework/Repositories/PlayItemRepository.cs b/src/Infrastructure.EntityFramework/Repositories/PlayItemRepository.cs new file mode 100644 index 0000000000..c8cf207b43 --- /dev/null +++ b/src/Infrastructure.EntityFramework/Repositories/PlayItemRepository.cs @@ -0,0 +1,42 @@ +using AutoMapper; +using Bit.Core.Repositories; +using Bit.Infrastructure.EntityFramework.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +#nullable enable + +namespace Bit.Infrastructure.EntityFramework.Repositories; + +public class PlayItemRepository : Repository, IPlayItemRepository +{ + public PlayItemRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) + : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.PlayItem) + { } + + public async Task> GetByPlayIdAsync(string playId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var playItemEntities = await GetDbSet(dbContext) + .Where(pd => pd.PlayId == playId) + .ToListAsync(); + return Mapper.Map>(playItemEntities); + } + } + + public async Task DeleteByPlayIdAsync(string playId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var entities = await GetDbSet(dbContext) + .Where(pd => pd.PlayId == playId) + .ToListAsync(); + + dbContext.PlayItem.RemoveRange(entities); + await dbContext.SaveChangesAsync(); + } + } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs index b196a07e9b..67be55f1a9 100644 --- a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs @@ -5,7 +5,6 @@ using System.Text.Json; using Bit.Core.Enums; using Bit.Core.Vault.Models.Data; using Bit.Infrastructure.EntityFramework.Vault.Models; - namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; public class UserCipherDetailsQuery : IQuery @@ -72,7 +71,7 @@ public class UserCipherDetailsQuery : IQuery OrganizationUseTotp = o.UseTotp, c.Reprompt, c.Key, - c.ArchivedDate + c.Archives }; var query2 = from c in dbContext.Ciphers @@ -96,7 +95,7 @@ public class UserCipherDetailsQuery : IQuery OrganizationUseTotp = false, c.Reprompt, c.Key, - c.ArchivedDate + c.Archives }; var union = query.Union(query2).Select(c => new CipherDetails @@ -118,11 +117,32 @@ public class UserCipherDetailsQuery : IQuery Manage = c.Manage, OrganizationUseTotp = c.OrganizationUseTotp, Key = c.Key, - ArchivedDate = c.ArchivedDate + ArchivedDate = GetArchivedDate(_userId, new Cipher { Id = c.Id, Archives = c.Archives }) }); return union; } + private static DateTime? GetArchivedDate(Guid? userId, Cipher cipher) + { + try + { + if (userId.HasValue && !string.IsNullOrWhiteSpace(cipher.Archives)) + { + var archives = JsonSerializer.Deserialize>(cipher.Archives); + if (archives.TryGetValue(userId.Value, out var archivedDate)) + { + return archivedDate; + } + } + + return null; + } + catch + { + return null; + } + } + private static Guid? GetFolderId(Guid? userId, Cipher cipher) { try diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index 56d64094d0..24c88d592c 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -510,6 +510,51 @@ public class UserRepository : Repository, IUserR }; } + public UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData, + string serverSideHashedMasterPasswordAuthenticationHash, string? masterPasswordHint) + { + return async (_, _) => + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var userEntity = await dbContext.Users.FindAsync(userId); + if (userEntity == null) + { + throw new ArgumentException("User not found", nameof(userId)); + } + + var timestamp = DateTime.UtcNow; + + userEntity.MasterPassword = serverSideHashedMasterPasswordAuthenticationHash; + userEntity.MasterPasswordHint = masterPasswordHint; + userEntity.Key = masterPasswordUnlockData.MasterKeyWrappedUserKey; + userEntity.Kdf = masterPasswordUnlockData.Kdf.KdfType; + userEntity.KdfIterations = masterPasswordUnlockData.Kdf.Iterations; + userEntity.KdfMemory = masterPasswordUnlockData.Kdf.Memory; + userEntity.KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism; + userEntity.RevisionDate = timestamp; + userEntity.AccountRevisionDate = timestamp; + + await dbContext.SaveChangesAsync(); + }; + } + + public async Task UpdateUserDataAsync(IEnumerable updateUserDataActions) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + foreach (var action in updateUserDataActions) + { + await action(); + } + + await transaction.CommitAsync(); + } + private static void MigrateDefaultUserCollectionsToShared(DatabaseContext dbContext, IEnumerable userIds) { var defaultCollections = (from c in dbContext.Collections diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index ebe39852f4..7b67e4c620 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -801,7 +801,7 @@ public class CipherRepository : Repository { dbContext.Attach(cipher); - cipher.ArchivedDate = action == CipherStateAction.Unarchive ? null : utcNow; + + // Build or load the per-user archives map + var archives = string.IsNullOrWhiteSpace(cipher.Archives) + ? new Dictionary() + : CoreHelpers.LoadClassFromJsonData>(cipher.Archives) + ?? new Dictionary(); + + if (action == CipherStateAction.Unarchive) + { + // Remove this user's archive record + archives.Remove(userId); + } + else if (action == CipherStateAction.Archive) + { + // Set this user's archive date + archives[userId] = utcNow; + } + + // Persist the updated JSON or clear it if empty + cipher.Archives = archives.Count == 0 + ? null + : CoreHelpers.ClassToJsonData(archives); + cipher.RevisionDate = utcNow; }); diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs index 880ee77854..43f368b232 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/Queries/CipherDetailsQuery.cs @@ -34,7 +34,9 @@ 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, + ArchivedDate = !_userId.HasValue || c.Archives == null || !c.Archives.ToLowerInvariant().Contains(_userId.Value.ToString()) ? + null : + CoreHelpers.LoadClassFromJsonData>(c.Archives)[_userId.Value], }; return query; } diff --git a/src/Notifications/Startup.cs b/src/Notifications/Startup.cs index 65904ea698..3a4dc2d447 100644 --- a/src/Notifications/Startup.cs +++ b/src/Notifications/Startup.cs @@ -5,7 +5,6 @@ using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Duende.IdentityModel; using Microsoft.AspNetCore.SignalR; -using Microsoft.IdentityModel.Logging; namespace Bit.Notifications; @@ -84,8 +83,6 @@ public class Startup IWebHostEnvironment env, GlobalSettings globalSettings) { - IdentityModelEventSource.ShowPII = true; - // Add general security headers app.UseMiddleware(); diff --git a/src/Notifications/appsettings.Production.json b/src/Notifications/appsettings.Production.json index 010f02f8cd..735c70e481 100644 --- a/src/Notifications/appsettings.Production.json +++ b/src/Notifications/appsettings.Production.json @@ -17,11 +17,9 @@ } }, "Logging": { - "IncludeScopes": false, "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" + "Default": "Information", + "Microsoft": "Warning" }, "Console": { "IncludeScopes": true, diff --git a/src/SharedWeb/Play/PlayServiceCollectionExtensions.cs b/src/SharedWeb/Play/PlayServiceCollectionExtensions.cs new file mode 100644 index 0000000000..2611d58bfb --- /dev/null +++ b/src/SharedWeb/Play/PlayServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Bit.Core.Repositories; +using Bit.SharedWeb.Play.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.SharedWeb.Play; + +public static class PlayServiceCollectionExtensions +{ + /// + /// Adds PlayId tracking decorators for User and Organization repositories using Dapper implementations. + /// This replaces the standard repository implementations with tracking versions + /// that record created entities for test data cleanup. Only call when TestPlayIdTrackingEnabled is true. + /// + public static void AddPlayIdTrackingDapperRepositories(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } + + /// + /// Adds PlayId tracking decorators for User and Organization repositories using EntityFramework implementations. + /// This replaces the standard repository implementations with tracking versions + /// that record created entities for test data cleanup. Only call when TestPlayIdTrackingEnabled is true. + /// + public static void AddPlayIdTrackingEFRepositories(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/src/SharedWeb/Play/Repositories/DapperTestOrganizationTrackingOrganizationRepository.cs b/src/SharedWeb/Play/Repositories/DapperTestOrganizationTrackingOrganizationRepository.cs new file mode 100644 index 0000000000..0d11d9c5eb --- /dev/null +++ b/src/SharedWeb/Play/Repositories/DapperTestOrganizationTrackingOrganizationRepository.cs @@ -0,0 +1,32 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.SharedWeb.Play.Repositories; + +/// +/// Dapper decorator around the that tracks +/// created Organizations for seeding. +/// +public class DapperTestOrganizationTrackingOrganizationRepository : OrganizationRepository +{ + private readonly IPlayItemService _playItemService; + + public DapperTestOrganizationTrackingOrganizationRepository( + IPlayItemService playItemService, + GlobalSettings globalSettings, + ILogger logger) + : base(globalSettings, logger) + { + _playItemService = playItemService; + } + + public override async Task CreateAsync(Organization obj) + { + var createdOrganization = await base.CreateAsync(obj); + await _playItemService.Record(createdOrganization); + return createdOrganization; + } +} diff --git a/src/SharedWeb/Play/Repositories/DapperTestUserTrackingUserRepository.cs b/src/SharedWeb/Play/Repositories/DapperTestUserTrackingUserRepository.cs new file mode 100644 index 0000000000..97954c0348 --- /dev/null +++ b/src/SharedWeb/Play/Repositories/DapperTestUserTrackingUserRepository.cs @@ -0,0 +1,33 @@ +using Bit.Core.Entities; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Infrastructure.Dapper.Repositories; +using Microsoft.AspNetCore.DataProtection; + +namespace Bit.SharedWeb.Play.Repositories; + +/// +/// Dapper decorator around the that tracks +/// created Users for seeding. +/// +public class DapperTestUserTrackingUserRepository : UserRepository +{ + private readonly IPlayItemService _playItemService; + + public DapperTestUserTrackingUserRepository( + IPlayItemService playItemService, + GlobalSettings globalSettings, + IDataProtectionProvider dataProtectionProvider) + : base(globalSettings, dataProtectionProvider) + { + _playItemService = playItemService; + } + + public override async Task CreateAsync(User user) + { + var createdUser = await base.CreateAsync(user); + + await _playItemService.Record(createdUser); + return createdUser; + } +} diff --git a/src/SharedWeb/Play/Repositories/EFTestOrganizationTrackingOrganizationRepository.cs b/src/SharedWeb/Play/Repositories/EFTestOrganizationTrackingOrganizationRepository.cs new file mode 100644 index 0000000000..fdf0e4d685 --- /dev/null +++ b/src/SharedWeb/Play/Repositories/EFTestOrganizationTrackingOrganizationRepository.cs @@ -0,0 +1,33 @@ +using AutoMapper; +using Bit.Core.Services; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bit.SharedWeb.Play.Repositories; + +/// +/// EntityFramework decorator around the that tracks +/// created Organizations for seeding. +/// +public class EFTestOrganizationTrackingOrganizationRepository : OrganizationRepository +{ + private readonly IPlayItemService _playItemService; + + public EFTestOrganizationTrackingOrganizationRepository( + IPlayItemService playItemService, + IServiceScopeFactory serviceScopeFactory, + IMapper mapper, + ILogger logger) + : base(serviceScopeFactory, mapper, logger) + { + _playItemService = playItemService; + } + + public override async Task CreateAsync(Core.AdminConsole.Entities.Organization organization) + { + var createdOrganization = await base.CreateAsync(organization); + await _playItemService.Record(createdOrganization); + return createdOrganization; + } +} diff --git a/src/SharedWeb/Play/Repositories/EFTestUserTrackingUserRepository.cs b/src/SharedWeb/Play/Repositories/EFTestUserTrackingUserRepository.cs new file mode 100644 index 0000000000..bafcd17ba0 --- /dev/null +++ b/src/SharedWeb/Play/Repositories/EFTestUserTrackingUserRepository.cs @@ -0,0 +1,31 @@ +using AutoMapper; +using Bit.Core.Services; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.SharedWeb.Play.Repositories; + +/// +/// EntityFramework decorator around the that tracks +/// created Users for seeding. +/// +public class EFTestUserTrackingUserRepository : UserRepository +{ + private readonly IPlayItemService _playItemService; + + public EFTestUserTrackingUserRepository( + IPlayItemService playItemService, + IServiceScopeFactory serviceScopeFactory, + IMapper mapper) + : base(serviceScopeFactory, mapper) + { + _playItemService = playItemService; + } + + public override async Task CreateAsync(Core.Entities.User user) + { + var createdUser = await base.CreateAsync(user); + await _playItemService.Record(createdUser); + return createdUser; + } +} diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj index d8dc61178d..b6036845b0 100644 --- a/src/SharedWeb/SharedWeb.csproj +++ b/src/SharedWeb/SharedWeb.csproj @@ -1,5 +1,10 @@ + + + $(WarningsNotAsErrors);CA1304 + + diff --git a/src/SharedWeb/Utilities/PlayIdMiddleware.cs b/src/SharedWeb/Utilities/PlayIdMiddleware.cs new file mode 100644 index 0000000000..c00ab2b657 --- /dev/null +++ b/src/SharedWeb/Utilities/PlayIdMiddleware.cs @@ -0,0 +1,41 @@ +using Bit.Core.Services; +using Microsoft.AspNetCore.Http; + +namespace Bit.SharedWeb.Utilities; + +/// +/// Middleware to extract the x-play-id header and set it in the PlayIdService. +/// +/// PlayId is used in testing infrastructure to track data created during automated testing and fa cilitate cleanup. +/// +/// +public sealed class PlayIdMiddleware(RequestDelegate next) +{ + private const int MaxPlayIdLength = 256; + + public async Task Invoke(HttpContext context, PlayIdService playIdService) + { + if (context.Request.Headers.TryGetValue("x-play-id", out var playId)) + { + var playIdValue = playId.ToString(); + + if (string.IsNullOrWhiteSpace(playIdValue)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new { Error = "x-play-id header cannot be empty or whitespace" }); + return; + } + + if (playIdValue.Length > MaxPlayIdLength) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new { Error = $"x-play-id header cannot exceed {MaxPlayIdLength} characters" }); + return; + } + + playIdService.PlayId = playIdValue; + } + + await next(context); + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 06d4c5d16e..820a79e446 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -21,6 +21,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.EmergencyAccess; using Bit.Core.Auth.UserFeatures.PasswordValidation; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; @@ -55,6 +56,7 @@ using Bit.Core.Vault; using Bit.Core.Vault.Services; using Bit.Infrastructure.Dapper; using Bit.Infrastructure.EntityFramework; +using Bit.SharedWeb.Play; using DnsClient; using Duende.IdentityModel; using LaunchDarkly.Sdk.Server; @@ -117,6 +119,40 @@ public static class ServiceCollectionExtensions return provider; } + /// + /// Registers test PlayId tracking services for test data management and cleanup. + /// This infrastructure is isolated to test environments and enables tracking of test-generated entities. + /// + public static void AddTestPlayIdTracking(this IServiceCollection services, GlobalSettings globalSettings) + { + if (globalSettings.TestPlayIdTrackingEnabled) + { + var (provider, _) = GetDatabaseProvider(globalSettings); + + // Include PlayIdService for tracking Play Ids in repositories + // We need the http context accessor to use the Singleton version, which pulls from the scoped version + services.AddHttpContextAccessor(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + // Replace standard repositories with PlayId tracking decorators + if (provider == SupportedDatabaseProviders.SqlServer) + { + services.AddPlayIdTrackingDapperRepositories(); + } + else + { + services.AddPlayIdTrackingEFRepositories(); + } + } + else + { + services.AddSingleton(); + } + } + public static void AddBaseServices(this IServiceCollection services, IGlobalSettings globalSettings) { services.AddScoped(); @@ -436,11 +472,6 @@ public static class ServiceCollectionExtensions addAuthorization.Invoke(config); }); } - - if (environment.IsDevelopment()) - { - Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true; - } } public static void AddCustomDataProtectionServices( @@ -522,6 +553,10 @@ public static class ServiceCollectionExtensions IWebHostEnvironment env, GlobalSettings globalSettings) { app.UseMiddleware(); + if (globalSettings.TestPlayIdTrackingEnabled) + { + app.UseMiddleware(); + } } public static void UseForwardedHeaders(this IApplicationBuilder app, IGlobalSettings globalSettings) @@ -626,7 +661,6 @@ public static class ServiceCollectionExtensions Constants.BrowserExtensions.OperaId }; } - }); } diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql new file mode 100644 index 0000000000..4e671bd1e4 --- /dev/null +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql @@ -0,0 +1,69 @@ +-- Creates default user collections for organization users +-- Filters out existing default collections at database level +CREATE PROCEDURE [dbo].[Collection_CreateDefaultCollections] + @OrganizationId UNIQUEIDENTIFIER, + @DefaultCollectionName VARCHAR(MAX), + @OrganizationUserCollectionIds AS [dbo].[TwoGuidIdArray] READONLY -- OrganizationUserId, CollectionId +AS +BEGIN + SET NOCOUNT ON + + DECLARE @Now DATETIME2(7) = GETUTCDATE() + + -- Filter to only users who don't have default collections + SELECT ids.Id1, ids.Id2 + INTO #FilteredIds + FROM @OrganizationUserCollectionIds ids + WHERE NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionUser] cu + INNER JOIN [dbo].[Collection] c ON c.Id = cu.CollectionId + WHERE c.OrganizationId = @OrganizationId + AND c.[Type] = 1 -- CollectionType.DefaultUserCollection + AND cu.OrganizationUserId = ids.Id1 + ); + + -- Insert collections only for users who don't have default collections yet + INSERT INTO [dbo].[Collection] + ( + [Id], + [OrganizationId], + [Name], + [CreationDate], + [RevisionDate], + [Type], + [ExternalId], + [DefaultUserCollectionEmail] + ) + SELECT + ids.Id2, -- CollectionId + @OrganizationId, + @DefaultCollectionName, + @Now, + @Now, + 1, -- CollectionType.DefaultUserCollection + NULL, + NULL + FROM + #FilteredIds ids; + + -- Insert collection user mappings + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + ids.Id2, -- CollectionId + ids.Id1, -- OrganizationUserId + 0, -- ReadOnly = false + 0, -- HidePasswords = false + 1 -- Manage = true + FROM + #FilteredIds ids; + + DROP TABLE #FilteredIds; +END diff --git a/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPassword.sql b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPassword.sql new file mode 100644 index 0000000000..42b3cbcb84 --- /dev/null +++ b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPassword.sql @@ -0,0 +1,30 @@ +CREATE PROCEDURE [dbo].[User_UpdateMasterPassword] + @Id UNIQUEIDENTIFIER, + @MasterPassword NVARCHAR(300), + @MasterPasswordHint NVARCHAR(50) = NULL, + @Key VARCHAR(MAX), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT = NULL, + @KdfParallelism INT = NULL, + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [MasterPassword] = @MasterPassword, + [MasterPasswordHint] = @MasterPasswordHint, + [Key] = @Key, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate + WHERE + [Id] = @Id +END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql index 64f3d81e08..4f781d2cc9 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByOrganizationIdWithClaimedDomains_V2.sql @@ -8,13 +8,14 @@ BEGIN SELECT * FROM [dbo].[OrganizationUserView] WHERE [OrganizationId] = @OrganizationId + AND [Status] != 0 -- Exclude invited users ), UserDomains AS ( SELECT U.[Id], U.[EmailDomain] FROM [dbo].[UserEmailDomainView] U WHERE EXISTS ( SELECT 1 - FROM [dbo].[OrganizationDomainView] OD + FROM [dbo].[OrganizationDomainView] OD WHERE OD.[OrganizationId] = @OrganizationId AND OD.[VerifiedDate] IS NOT NULL AND OD.[DomainName] = U.[EmailDomain] diff --git a/src/Sql/dbo/Stored Procedures/Organization_Create.sql b/src/Sql/dbo/Stored Procedures/Organization_Create.sql index 4fc4681648..f159181644 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Create.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Create.sql @@ -60,7 +60,8 @@ CREATE PROCEDURE [dbo].[Organization_Create] @UseAdminSponsoredFamilies BIT = 0, @SyncSeats BIT = 0, @UseAutomaticUserConfirmation BIT = 0, - @UsePhishingBlocker BIT = 0 + @UsePhishingBlocker BIT = 0, + @UseDisableSmAdsForUsers BIT = 0 AS BEGIN SET NOCOUNT ON @@ -129,7 +130,8 @@ BEGIN [SyncSeats], [UseAutomaticUserConfirmation], [UsePhishingBlocker], - [MaxStorageGbIncreased] + [MaxStorageGbIncreased], + [UseDisableSmAdsForUsers] ) VALUES ( @@ -195,6 +197,7 @@ BEGIN @SyncSeats, @UseAutomaticUserConfirmation, @UsePhishingBlocker, - @MaxStorageGb + @MaxStorageGb, + @UseDisableSmAdsForUsers ); END diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql index 9efefe8d54..bf9edbc12b 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_ReadAbilities.sql @@ -28,8 +28,9 @@ BEGIN [LimitItemDeletion], [UseOrganizationDomains], [UseAdminSponsoredFamilies], - [UseAutomaticUserConfirmation], - [UsePhishingBlocker] + [UseAutomaticUserConfirmation], + [UsePhishingBlocker], + [UseDisableSmAdsForUsers] FROM [dbo].[Organization] END diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql index 583f548c8b..ee14c2c52a 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_ReadByClaimedUserEmailDomain.sql @@ -6,7 +6,7 @@ BEGIN WITH CTE_User AS ( SELECT - U.*, + U.[Id], SUBSTRING(U.Email, CHARINDEX('@', U.Email) + 1, LEN(U.Email)) AS EmailDomain FROM dbo.[UserView] U WHERE U.[Id] = @UserId @@ -19,4 +19,5 @@ BEGIN WHERE OD.[VerifiedDate] IS NOT NULL AND CU.EmailDomain = OD.[DomainName] AND O.[Enabled] = 1 + AND OU.[Status] != 0 -- Exclude invited users END diff --git a/src/Sql/dbo/Stored Procedures/Organization_Update.sql b/src/Sql/dbo/Stored Procedures/Organization_Update.sql index 946cf03e94..aac924537f 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_Update.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_Update.sql @@ -60,7 +60,8 @@ CREATE PROCEDURE [dbo].[Organization_Update] @UseAdminSponsoredFamilies BIT = 0, @SyncSeats BIT = 0, @UseAutomaticUserConfirmation BIT = 0, - @UsePhishingBlocker BIT = 0 + @UsePhishingBlocker BIT = 0, + @UseDisableSmAdsForUsers BIT = 0 AS BEGIN SET NOCOUNT ON @@ -129,7 +130,8 @@ BEGIN [SyncSeats] = @SyncSeats, [UseAutomaticUserConfirmation] = @UseAutomaticUserConfirmation, [UsePhishingBlocker] = @UsePhishingBlocker, - [MaxStorageGbIncreased] = @MaxStorageGb + [MaxStorageGbIncreased] = @MaxStorageGb, + [UseDisableSmAdsForUsers] = @UseDisableSmAdsForUsers WHERE [Id] = @Id; END diff --git a/src/Sql/dbo/Stored Procedures/PlayItem_Create.sql b/src/Sql/dbo/Stored Procedures/PlayItem_Create.sql new file mode 100644 index 0000000000..cf75d03d10 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PlayItem_Create.sql @@ -0,0 +1,27 @@ +CREATE PROCEDURE [dbo].[PlayItem_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @PlayId NVARCHAR(256), + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CreationDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[PlayItem] + ( + [Id], + [PlayId], + [UserId], + [OrganizationId], + [CreationDate] + ) + VALUES + ( + @Id, + @PlayId, + @UserId, + @OrganizationId, + @CreationDate + ) +END diff --git a/src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql b/src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql new file mode 100644 index 0000000000..5e2a102e40 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[PlayItem_DeleteByPlayId] + @PlayId NVARCHAR(256) +AS +BEGIN + SET NOCOUNT ON + + DELETE + FROM + [dbo].[PlayItem] + WHERE + [PlayId] = @PlayId +END diff --git a/src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql b/src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql new file mode 100644 index 0000000000..29592c527b --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[PlayItem_ReadByPlayId] + @PlayId NVARCHAR(256) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [Id], + [PlayId], + [UserId], + [OrganizationId], + [CreationDate] + FROM + [dbo].[PlayItem] + WHERE + [PlayId] = @PlayId +END diff --git a/src/Sql/dbo/Tables/Organization.sql b/src/Sql/dbo/Tables/Organization.sql index f07cd4ce0d..17e64af9a2 100644 --- a/src/Sql/dbo/Tables/Organization.sql +++ b/src/Sql/dbo/Tables/Organization.sql @@ -62,6 +62,7 @@ CREATE TABLE [dbo].[Organization] ( [UseAutomaticUserConfirmation] BIT NOT NULL CONSTRAINT [DF_Organization_UseAutomaticUserConfirmation] DEFAULT (0), [MaxStorageGbIncreased] SMALLINT NULL, [UsePhishingBlocker] BIT NOT NULL CONSTRAINT [DF_Organization_UsePhishingBlocker] DEFAULT (0), + [UseDisableSmAdsForUsers] BIT NOT NULL CONSTRAINT [DF_Organization_UseDisableSmAdsForUsers] DEFAULT (0), CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC) ); diff --git a/src/Sql/dbo/Tables/PlayItem.sql b/src/Sql/dbo/Tables/PlayItem.sql new file mode 100644 index 0000000000..fbf3d2e0dd --- /dev/null +++ b/src/Sql/dbo/Tables/PlayItem.sql @@ -0,0 +1,23 @@ +CREATE TABLE [dbo].[PlayItem] ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [PlayId] NVARCHAR (256) NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL, + [OrganizationId] UNIQUEIDENTIFIER NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_PlayItem] PRIMARY KEY CLUSTERED ([Id] ASC), + CONSTRAINT [FK_PlayItem_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_PlayItem_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE, + CONSTRAINT [CK_PlayItem_UserOrOrganization] CHECK (([UserId] IS NOT NULL AND [OrganizationId] IS NULL) OR ([UserId] IS NULL AND [OrganizationId] IS NOT NULL)) +); + +GO +CREATE NONCLUSTERED INDEX [IX_PlayItem_PlayId] + ON [dbo].[PlayItem]([PlayId] ASC); + +GO +CREATE NONCLUSTERED INDEX [IX_PlayItem_UserId] + ON [dbo].[PlayItem]([UserId] ASC); + +GO +CREATE NONCLUSTERED INDEX [IX_PlayItem_OrganizationId] + ON [dbo].[PlayItem]([OrganizationId] ASC); diff --git a/src/Sql/dbo/Tools/Stored Procedures/Send_Create.sql b/src/Sql/dbo/Tools/Stored Procedures/Send_Create.sql index 752f8fb496..e277174717 100644 --- a/src/Sql/dbo/Tools/Stored Procedures/Send_Create.sql +++ b/src/Sql/dbo/Tools/Stored Procedures/Send_Create.sql @@ -18,7 +18,8 @@ -- FIXME: remove null default value once this argument has been -- in 2 server releases @Emails NVARCHAR(4000) = NULL, - @AuthType TINYINT = NULL + @AuthType TINYINT = NULL, + @EmailHashes NVARCHAR(4000) = NULL AS BEGIN SET NOCOUNT ON @@ -42,7 +43,8 @@ BEGIN [HideEmail], [CipherId], [Emails], - [AuthType] + [AuthType], + [EmailHashes] ) VALUES ( @@ -63,7 +65,8 @@ BEGIN @HideEmail, @CipherId, @Emails, - @AuthType + @AuthType, + @EmailHashes ) IF @UserId IS NOT NULL diff --git a/src/Sql/dbo/Tools/Stored Procedures/Send_Update.sql b/src/Sql/dbo/Tools/Stored Procedures/Send_Update.sql index fba842d8d6..a2bcb0a24b 100644 --- a/src/Sql/dbo/Tools/Stored Procedures/Send_Update.sql +++ b/src/Sql/dbo/Tools/Stored Procedures/Send_Update.sql @@ -16,7 +16,8 @@ @HideEmail BIT, @CipherId UNIQUEIDENTIFIER = NULL, @Emails NVARCHAR(4000) = NULL, - @AuthType TINYINT = NULL + @AuthType TINYINT = NULL, + @EmailHashes NVARCHAR(4000) = NULL AS BEGIN SET NOCOUNT ON @@ -40,7 +41,8 @@ BEGIN [HideEmail] = @HideEmail, [CipherId] = @CipherId, [Emails] = @Emails, - [AuthType] = @AuthType + [AuthType] = @AuthType, + [EmailHashes] = @EmailHashes WHERE [Id] = @Id diff --git a/src/Sql/dbo/Tools/Tables/Send.sql b/src/Sql/dbo/Tools/Tables/Send.sql index 94311d6328..59a42a2aa5 100644 --- a/src/Sql/dbo/Tools/Tables/Send.sql +++ b/src/Sql/dbo/Tools/Tables/Send.sql @@ -1,22 +1,24 @@ -CREATE TABLE [dbo].[Send] ( +CREATE TABLE [dbo].[Send] +( [Id] UNIQUEIDENTIFIER NOT NULL, [UserId] UNIQUEIDENTIFIER NULL, [OrganizationId] UNIQUEIDENTIFIER NULL, [Type] TINYINT NOT NULL, [Data] VARCHAR(MAX) NOT NULL, - [Key] VARCHAR (MAX) NOT NULL, - [Password] NVARCHAR (300) NULL, - [Emails] NVARCHAR (4000) NULL, + [Key] VARCHAR(MAX) NOT NULL, + [Password] NVARCHAR(300) NULL, + [Emails] NVARCHAR(4000) NULL, [MaxAccessCount] INT NULL, [AccessCount] INT NOT NULL, - [CreationDate] DATETIME2 (7) NOT NULL, - [RevisionDate] DATETIME2 (7) NOT NULL, - [ExpirationDate] DATETIME2 (7) NULL, - [DeletionDate] DATETIME2 (7) NOT NULL, + [CreationDate] DATETIME2(7) NOT NULL, + [RevisionDate] DATETIME2(7) NOT NULL, + [ExpirationDate] DATETIME2(7) NULL, + [DeletionDate] DATETIME2(7) NOT NULL, [Disabled] BIT NOT NULL, [HideEmail] BIT NULL, [CipherId] UNIQUEIDENTIFIER NULL, [AuthType] TINYINT NULL, + [EmailHashes] NVARCHAR(4000) NULL, CONSTRAINT [PK_Send] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [FK_Send_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]), CONSTRAINT [FK_Send_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]), @@ -26,9 +28,9 @@ GO CREATE NONCLUSTERED INDEX [IX_Send_UserId_OrganizationId] - ON [dbo].[Send]([UserId] ASC, [OrganizationId] ASC); + ON [dbo].[Send] ([UserId] ASC, [OrganizationId] ASC); GO CREATE NONCLUSTERED INDEX [IX_Send_DeletionDate] - ON [dbo].[Send]([DeletionDate] ASC); + ON [dbo].[Send] ([DeletionDate] ASC); diff --git a/src/Sql/dbo/Vault/Functions/CipherDetails.sql b/src/Sql/dbo/Vault/Functions/CipherDetails.sql index ed92c11cb6..51930ec10e 100644 --- a/src/Sql/dbo/Vault/Functions/CipherDetails.sql +++ b/src/Sql/dbo/Vault/Functions/CipherDetails.sql @@ -28,6 +28,12 @@ SELECT C.[DeletedDate], C.[Reprompt], C.[Key], - C.[ArchivedDate] + CASE + WHEN + @UserId IS NULL + OR C.[Archives] IS NULL + THEN NULL + ELSE TRY_CONVERT(DATETIME2(7), JSON_VALUE(C.[Archives], CONCAT('$."', @UserId, '"'))) + END [ArchivedDate] FROM - [dbo].[Cipher] C + [dbo].[Cipher] C; diff --git a/src/Sql/dbo/Vault/Functions/UserCipherDetails.sql b/src/Sql/dbo/Vault/Functions/UserCipherDetails.sql index e7933572cd..f91c63374e 100644 --- a/src/Sql/dbo/Vault/Functions/UserCipherDetails.sql +++ b/src/Sql/dbo/Vault/Functions/UserCipherDetails.sql @@ -19,9 +19,9 @@ SELECT ELSE 0 END [Edit], CASE - WHEN COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0 - THEN 1 - ELSE 0 + WHEN COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0 + THEN 1 + ELSE 0 END [ViewPassword], CASE WHEN COALESCE(CU.[Manage], CG.[Manage], 0) = 1 @@ -64,4 +64,4 @@ SELECT FROM [dbo].[CipherDetails](@UserId) WHERE - [UserId] = @UserId + [UserId] = @UserId; 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 254110f059..846f3bcf8a 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql @@ -18,7 +18,8 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @ArchivedDate DATETIME2(7) = NULL + @ArchivedDate DATETIME2(7) = NULL, + @Archives NVARCHAR(MAX) = NULL -- not used AS BEGIN SET NOCOUNT ON @@ -40,7 +41,7 @@ BEGIN [DeletedDate], [Reprompt], [Key], - [ArchivedDate] + [Archives] ) VALUES ( @@ -56,7 +57,7 @@ BEGIN @DeletedDate, @Reprompt, @Key, - @ArchivedDate + CASE WHEN @ArchivedDate IS NOT NULL THEN CONCAT('{', @UserIdKey, ':"', CONVERT(NVARCHAR(30), @ArchivedDate, 127), '"}') ELSE NULL END ) 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 6082e89efc..da573b1d13 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql @@ -18,8 +18,9 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @CollectionIds AS [dbo].[GuidIdArray] READONLY, - @ArchivedDate DATETIME2(7) = NULL + @ArchivedDate DATETIME2(7) = NULL, + @Archives NVARCHAR(MAX) = NULL, -- not used + @CollectionIds AS [dbo].[GuidIdArray] READONLY AS BEGIN SET NOCOUNT ON 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 c17f5761ff..4676f20fd3 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql @@ -18,7 +18,8 @@ @DeletedDate DATETIME2(2), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @ArchivedDate DATETIME2(7) = NULL + @ArchivedDate DATETIME2(7) = NULL, + @Archives NVARCHAR(MAX) = NULL -- not used AS BEGIN SET NOCOUNT ON @@ -51,13 +52,21 @@ BEGIN ELSE JSON_MODIFY([Favorites], @UserIdPath, NULL) END, + [Archives] = + CASE + WHEN @ArchivedDate IS NOT NULL AND [Archives] IS NULL THEN + CONCAT('{', @UserIdKey, ':"', CONVERT(NVARCHAR(30), @ArchivedDate, 127), '"}') + WHEN @ArchivedDate IS NOT NULL THEN + JSON_MODIFY([Archives], @UserIdPath, CONVERT(NVARCHAR(30), @ArchivedDate, 127)) + ELSE + JSON_MODIFY([Archives], @UserIdPath, NULL) + END, [Attachments] = @Attachments, [Reprompt] = @Reprompt, [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate, [DeletedDate] = @DeletedDate, - [Key] = @Key, - [ArchivedDate] = @ArchivedDate + [Key] = @Key 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 index 68f11c0d4f..605936043c 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql @@ -13,23 +13,27 @@ BEGIN INSERT INTO #Temp SELECT - [Id], - [UserId] + ucd.[Id], + ucd.[UserId] FROM - [dbo].[UserCipherDetails](@UserId) + [dbo].[UserCipherDetails](@UserId) ucd + INNER JOIN @Ids ids ON ids.Id = ucd.[Id] WHERE - [Edit] = 1 - AND [ArchivedDate] IS NULL - AND [Id] IN (SELECT * FROM @Ids) + ucd.[ArchivedDate] IS NULL DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); UPDATE [dbo].[Cipher] SET - [ArchivedDate] = @UtcNow, + [Archives] = JSON_MODIFY( + COALESCE([Archives], N'{}'), + CONCAT('$."', @UserId, '"'), + CONVERT(NVARCHAR(30), @UtcNow, 127) + ), [RevisionDate] = @UtcNow - WHERE - [Id] IN (SELECT [Id] FROM #Temp) + FROM [dbo].[Cipher] AS c + INNER JOIN #Temp AS t + ON t.[Id] = c.[Id]; EXEC [dbo].[User_BumpAccountRevisionDate] @UserId 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 eb49136895..68e7b11c07 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql @@ -12,7 +12,7 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @ArchivedDate DATETIME2(7) = NULL + @Archives NVARCHAR(MAX) = NULL AS BEGIN SET NOCOUNT ON @@ -31,7 +31,7 @@ BEGIN [DeletedDate], [Reprompt], [Key], - [ArchivedDate] + [Archives] ) VALUES ( @@ -47,7 +47,7 @@ BEGIN @DeletedDate, @Reprompt, @Key, - @ArchivedDate + @Archives ) 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 c6816a1226..071bf82f87 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,14 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @CollectionIds AS [dbo].[GuidIdArray] READONLY, - @ArchivedDate DATETIME2(7) = NULL + @Archives NVARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY AS BEGIN SET NOCOUNT ON EXEC [dbo].[Cipher_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, - @Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @ArchivedDate + @Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @Archives DECLARE @UpdateCollectionsSuccess INT EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_SoftDelete.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_SoftDelete.sql index 0e354f13e5..6c1c920c4e 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_SoftDelete.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_SoftDelete.sql @@ -6,7 +6,7 @@ BEGIN SET NOCOUNT ON CREATE TABLE #Temp - ( + ( [Id] UNIQUEIDENTIFIER NOT NULL, [UserId] UNIQUEIDENTIFIER NULL, [OrganizationId] UNIQUEIDENTIFIER NULL diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql index c2b7b10619..f342adf2ac 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql @@ -13,23 +13,27 @@ BEGIN INSERT INTO #Temp SELECT - [Id], - [UserId] + ucd.[Id], + ucd.[UserId] FROM - [dbo].[UserCipherDetails](@UserId) + [dbo].[UserCipherDetails](@UserId) ucd + INNER JOIN @Ids ids ON ids.Id = ucd.[Id] WHERE - [Edit] = 1 - AND [ArchivedDate] IS NOT NULL - AND [Id] IN (SELECT * FROM @Ids) + ucd.[ArchivedDate] IS NOT NULL DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); UPDATE [dbo].[Cipher] SET - [ArchivedDate] = NULL, + [Archives] = JSON_MODIFY( + COALESCE([Archives], N'{}'), + CONCAT('$."', @UserId, '"'), + NULL + ), [RevisionDate] = @UtcNow - WHERE - [Id] IN (SELECT [Id] FROM #Temp) + FROM [dbo].[Cipher] AS c + INNER JOIN #Temp AS t + ON t.[Id] = c.[Id]; EXEC [dbo].[User_BumpAccountRevisionDate] @UserId 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 912badc906..e92a8147ea 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql @@ -12,7 +12,7 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @ArchivedDate DATETIME2(7) = NULL + @Archives NVARCHAR(MAX) = NULL AS BEGIN SET NOCOUNT ON @@ -32,7 +32,7 @@ BEGIN [DeletedDate] = @DeletedDate, [Reprompt] = @Reprompt, [Key] = @Key, - [ArchivedDate] = @ArchivedDate + [Archives] = @Archives 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 3fe877c168..c33d49e92e 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql @@ -12,8 +12,8 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @CollectionIds AS [dbo].[GuidIdArray] READONLY, - @ArchivedDate DATETIME2(7) = NULL + @Archives NVARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY AS BEGIN SET NOCOUNT ON @@ -40,10 +40,10 @@ BEGIN [RevisionDate] = @RevisionDate, [DeletedDate] = @DeletedDate, [Key] = @Key, - [ArchivedDate] = @ArchivedDate, [Folders] = @Folders, [Favorites] = @Favorites, - [Reprompt] = @Reprompt + [Reprompt] = @Reprompt, + [Archives] = @Archives -- No need to update CreationDate or Type since that data will not change WHERE [Id] = @Id diff --git a/src/Sql/dbo/Vault/Tables/Cipher.sql b/src/Sql/dbo/Vault/Tables/Cipher.sql index d69035a0a9..75ebc1d618 100644 --- a/src/Sql/dbo/Vault/Tables/Cipher.sql +++ b/src/Sql/dbo/Vault/Tables/Cipher.sql @@ -14,6 +14,7 @@ CREATE TABLE [dbo].[Cipher] ( [Reprompt] TINYINT NULL, [Key] VARCHAR(MAX) NULL, [ArchivedDate] DATETIME2 (7) NULL, + [Archives] NVARCHAR(MAX) 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]) diff --git a/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql b/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql index ffd6810b1b..b4f88b6a2e 100644 --- a/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql +++ b/src/Sql/dbo/Views/OrganizationUserOrganizationDetailsView.sql @@ -56,7 +56,8 @@ SELECT O.[UseOrganizationDomains], OS.[IsAdminInitiated], O.[UseAutomaticUserConfirmation], - O.[UsePhishingBlocker] + O.[UsePhishingBlocker], + O.[UseDisableSmAdsForUsers] FROM [dbo].[OrganizationUser] OU LEFT JOIN diff --git a/src/Sql/dbo/Views/OrganizationView.sql b/src/Sql/dbo/Views/OrganizationView.sql index 6e42d08338..5eebc28d3c 100644 --- a/src/Sql/dbo/Views/OrganizationView.sql +++ b/src/Sql/dbo/Views/OrganizationView.sql @@ -62,6 +62,7 @@ SELECT [UseAdminSponsoredFamilies], [SyncSeats], [UseAutomaticUserConfirmation], - [UsePhishingBlocker] + [UsePhishingBlocker], + [UseDisableSmAdsForUsers] FROM [dbo].[Organization] diff --git a/src/Sql/dbo/Views/ProviderUserProviderOrganizationDetailsView.sql b/src/Sql/dbo/Views/ProviderUserProviderOrganizationDetailsView.sql index e1d5ef9144..dc044c7d33 100644 --- a/src/Sql/dbo/Views/ProviderUserProviderOrganizationDetailsView.sql +++ b/src/Sql/dbo/Views/ProviderUserProviderOrganizationDetailsView.sql @@ -45,7 +45,8 @@ SELECT O.[UseAutomaticUserConfirmation], SS.[Enabled] SsoEnabled, SS.[Data] SsoConfig, - O.[UsePhishingBlocker] + O.[UsePhishingBlocker], + O.[UseDisableSmAdsForUsers] FROM [dbo].[ProviderUser] PU INNER JOIN diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs index 71c6bf104c..a70be7d557 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/GroupsControllerPerformanceTests.cs @@ -1,11 +1,14 @@ using System.Net; using System.Text; using System.Text.Json; +using AutoMapper; using Bit.Api.AdminConsole.Models.Request; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Request; +using Bit.Core.Entities; using Bit.Seeder.Recipes; +using Microsoft.AspNetCore.Identity; using Xunit; using Xunit.Abstractions; @@ -26,7 +29,9 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var groupsSeeder = new GroupsRecipe(db); @@ -34,8 +39,8 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - var collectionIds = collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); - var groupIds = groupsSeeder.AddToOrganization(orgId, 1, orgUserIds, 0); + var collectionIds = collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0); + var groupIds = groupsSeeder.Seed(orgId, 1, orgUserIds, 0); var groupId = groupIds.First(); diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs index 6645f29eae..3ea7d9ff5a 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerBulkRevokeTests.cs @@ -4,7 +4,6 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Response; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -14,8 +13,6 @@ using Bit.Core.Billing.Enums; 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; @@ -32,12 +29,6 @@ public class OrganizationUserControllerBulkRevokeTests : IClassFixture(featureService => - { - featureService - .IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2) - .Returns(true); - }); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs index 0fef4a0cd0..343178e7a2 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs @@ -5,7 +5,6 @@ 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; @@ -14,8 +13,6 @@ 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; @@ -28,12 +25,6 @@ public class OrganizationUserControllerTests : IClassFixture(featureService => - { - featureService - .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) - .Returns(true); - }); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs index fc64930777..322fd62bd7 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPerformanceTests.cs @@ -1,13 +1,16 @@ using System.Net; using System.Text; using System.Text.Json; +using AutoMapper; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Request; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Seeder.Recipes; +using Microsoft.AspNetCore.Identity; using Xunit; using Xunit.Abstractions; @@ -28,7 +31,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var groupsSeeder = new GroupsRecipe(db); @@ -37,8 +42,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds); - groupsSeeder.AddToOrganization(orgId, 5, orgUserIds); + collectionsSeeder.Seed(orgId, 10, orgUserIds); + groupsSeeder.Seed(orgId, 5, orgUserIds); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -64,7 +69,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var groupsSeeder = new GroupsRecipe(db); @@ -72,8 +79,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds); - groupsSeeder.AddToOrganization(orgId, 5, orgUserIds); + collectionsSeeder.Seed(orgId, 10, orgUserIds); + groupsSeeder.Seed(orgId, 5, orgUserIds); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -98,14 +105,16 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var groupsSeeder = new GroupsRecipe(db); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault(); - groupsSeeder.AddToOrganization(orgId, 2, [orgUserId]); + groupsSeeder.Seed(orgId, 2, [orgUserId]); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -130,7 +139,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); @@ -163,7 +174,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed( @@ -211,7 +224,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); @@ -251,7 +266,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed( @@ -295,7 +312,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed( @@ -339,7 +358,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domainSeeder = new OrganizationDomainRecipe(db); var domain = OrganizationTestHelpers.GenerateRandomDomain(); @@ -350,7 +371,7 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO users: userCount, usersStatus: OrganizationUserStatusType.Confirmed); - domainSeeder.AddVerifiedDomainToOrganization(orgId, domain); + domainSeeder.Seed(orgId, domain); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -384,7 +405,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var groupsSeeder = new GroupsRecipe(db); @@ -392,8 +415,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - var collectionIds = collectionsSeeder.AddToOrganization(orgId, 3, orgUserIds, 0); - var groupIds = groupsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0); + var collectionIds = collectionsSeeder.Seed(orgId, 3, orgUserIds, 0); + var groupIds = groupsSeeder.Seed(orgId, 2, orgUserIds, 0); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -434,7 +457,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); @@ -471,7 +496,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domainSeeder = new OrganizationDomainRecipe(db); var domain = OrganizationTestHelpers.GenerateRandomDomain(); @@ -481,7 +508,7 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO users: 2, usersStatus: OrganizationUserStatusType.Confirmed); - domainSeeder.AddVerifiedDomainToOrganization(orgId, domain); + domainSeeder.Seed(orgId, domain); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -512,14 +539,16 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - var collectionIds = collectionsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0); + var collectionIds = collectionsSeeder.Seed(orgId, 2, orgUserIds, 0); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -560,7 +589,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var domain = OrganizationTestHelpers.GenerateRandomDomain(); var orgId = orgSeeder.Seed( diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerSelfRevokeTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerSelfRevokeTests.cs new file mode 100644 index 0000000000..896fb3bd20 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerSelfRevokeTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums.Provider; +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.Controllers; + +public class OrganizationUsersControllerSelfRevokeTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private string _ownerEmail = null!; + + public OrganizationUsersControllerSelfRevokeTests(ApiApplicationFactory apiFactory) + { + _factory = apiFactory; + _client = _factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task SelfRevoke_WhenPolicyEnabledAndUserIsEligible_ReturnsOk() + { + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }; + await _factory.GetService().CreateAsync(policy); + + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, + organization.Id, + OrganizationUserType.User); + await _loginHelper.LoginAsync(userEmail); + + var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null); + + Assert.Equal(HttpStatusCode.NoContent, result.StatusCode); + + var organizationUserRepository = _factory.GetService(); + var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organization.Id); + var revokedUser = organizationUsers.FirstOrDefault(u => u.Email == userEmail); + + Assert.NotNull(revokedUser); + Assert.Equal(OrganizationUserStatusType.Revoked, revokedUser.Status); + } + + [Fact] + public async Task SelfRevoke_WhenUserNotMemberOfOrganization_ReturnsForbidden() + { + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }; + await _factory.GetService().CreateAsync(policy); + + var nonMemberEmail = $"{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(nonMemberEmail); + await _loginHelper.LoginAsync(nonMemberEmail); + + var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null); + + Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode); + } + + [Theory] + [InlineData(OrganizationUserType.Owner)] + [InlineData(OrganizationUserType.Admin)] + public async Task SelfRevoke_WhenUserIsOwnerOrAdmin_ReturnsBadRequest(OrganizationUserType userType) + { + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }; + await _factory.GetService().CreateAsync(policy); + + string userEmail; + if (userType == OrganizationUserType.Owner) + { + userEmail = _ownerEmail; + } + else + { + (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, + organization.Id, + userType); + } + + await _loginHelper.LoginAsync(userEmail); + + var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null); + + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + } + + [Fact] + public async Task SelfRevoke_WhenUserIsProviderButNotMember_ReturnsForbidden() + { + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }; + await _factory.GetService().CreateAsync(policy); + + var provider = await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync( + _factory, + organization.Id, + ProviderType.Msp, + ProviderStatusType.Billable); + + var providerUserEmail = $"{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(providerUserEmail); + await ProviderTestHelpers.CreateProviderUserAsync( + _factory, + provider.Id, + providerUserEmail, + ProviderUserType.ProviderAdmin); + + await _loginHelper.LoginAsync(providerUserEmail); + + var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null); + + Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode); + } +} diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs index 238a9a5d53..025eacc432 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationsControllerPerformanceTests.cs @@ -1,14 +1,17 @@ using System.Net; using System.Text; using System.Text.Json; +using AutoMapper; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.Billing.Enums; +using Bit.Core.Entities; using Bit.Core.Tokens; using Bit.Seeder.Recipes; +using Microsoft.AspNetCore.Identity; using Xunit; using Xunit.Abstractions; @@ -29,7 +32,9 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var groupsSeeder = new GroupsRecipe(db); @@ -37,8 +42,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); - groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0); + collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0); + groupsSeeder.Seed(orgId, groupCount, orgUserIds, 0); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); @@ -77,7 +82,9 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu var client = factory.CreateClient(); var db = factory.GetDatabaseContext(); - var orgSeeder = new OrganizationWithUsersRecipe(db); + var mapper = factory.GetService(); + var passwordHasher = factory.GetService>(); + var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher); var collectionsSeeder = new CollectionsRecipe(db); var groupsSeeder = new GroupsRecipe(db); @@ -85,8 +92,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount); var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList(); - collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0); - groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0); + collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0); + groupsSeeder.Seed(orgId, groupCount, orgUserIds, 0); await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}"); diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs index e4098ce9a9..d58538ae1c 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs @@ -150,8 +150,8 @@ public class PoliciesControllerTests : IClassFixture, IAs Enabled = true, Data = new Dictionary { - { "minComplexity", 10 }, - { "minLength", 12 }, + { "minComplexity", 4 }, + { "minLength", 128 }, { "requireUpper", true }, { "requireLower", false }, { "requireNumbers", true }, @@ -397,4 +397,48 @@ public class PoliciesControllerTests : IClassFixture, IAs // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + + [Fact] + public async Task Put_MasterPasswordPolicy_ExcessiveMinLength_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new PolicyRequestModel + { + Enabled = true, + Data = new Dictionary + { + { "minLength", 129 } + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}", + JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Put_MasterPasswordPolicy_ExcessiveMinComplexity_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new PolicyRequestModel + { + Enabled = true, + Data = new Dictionary + { + { "minComplexity", 5 } + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}", + JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } } diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs index 9f2512038e..e4bdbdb174 100644 --- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs @@ -264,4 +264,138 @@ public class MembersControllerTests : IClassFixture, IAsy new Permissions { CreateNewCollections = true, ManageScim = true, ManageGroups = true, ManageUsers = true }, orgUser.GetPermissions()); } + + [Fact] + public async Task Revoke_Member_Success() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var updatedUser = await _factory.GetService() + .GetByIdAsync(orgUser.Id); + Assert.NotNull(updatedUser); + Assert.Equal(OrganizationUserStatusType.Revoked, updatedUser.Status); + } + + [Fact] + public async Task Revoke_AlreadyRevoked_ReturnsBadRequest() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var revokeResponse = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null); + Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode); + + var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(); + Assert.Equal("Already revoked.", error?.Message); + } + + [Fact] + public async Task Revoke_NotFound_ReturnsNotFound() + { + var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/revoke", null); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Revoke_DifferentOrganization_ReturnsNotFound() + { + // Create a different organization + var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + // Create a user in the other organization + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, otherOrganization.Id, OrganizationUserType.User); + + // Re-authenticate with the original organization + await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id); + + // Try to revoke the user from the other organization + var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Restore_Member_Success() + { + // Invite a user to revoke + var email = $"integration-test{Guid.NewGuid()}@example.com"; + var inviteRequest = new MemberCreateRequestModel + { + Email = email, + Type = OrganizationUserType.User, + }; + + var inviteResponse = await _client.PostAsync("/public/members", JsonContent.Create(inviteRequest)); + Assert.Equal(HttpStatusCode.OK, inviteResponse.StatusCode); + var invitedMember = await inviteResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(invitedMember); + + // Revoke the invited user + var revokeResponse = await _client.PostAsync($"/public/members/{invitedMember.Id}/revoke", null); + Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode); + + // Restore the user + var response = await _client.PostAsync($"/public/members/{invitedMember.Id}/restore", null); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Verify user is restored to Invited state + var updatedUser = await _factory.GetService() + .GetByIdAsync(invitedMember.Id); + Assert.NotNull(updatedUser); + Assert.Equal(OrganizationUserStatusType.Invited, updatedUser.Status); + } + + [Fact] + public async Task Restore_AlreadyActive_ReturnsBadRequest() + { + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + + var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.Content.ReadFromJsonAsync(); + Assert.Equal("Already active.", error?.Message); + } + + [Fact] + public async Task Restore_NotFound_ReturnsNotFound() + { + var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/restore", null); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Restore_DifferentOrganization_ReturnsNotFound() + { + // Create a different organization + var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + // Create a user in the other organization + var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, otherOrganization.Id, OrganizationUserType.User); + + // Re-authenticate with the original organization + await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id); + + // Try to restore the user from the other organization + var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } } diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs index 6144d7eebb..a669bdd93c 100644 --- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs @@ -61,7 +61,8 @@ public class PoliciesControllerTests : IClassFixture, IAs Enabled = true, Data = new Dictionary { - { "minComplexity", 15}, + { "minComplexity", 4}, + { "minLength", 128 }, { "requireLower", true} } }; @@ -78,7 +79,8 @@ public class PoliciesControllerTests : IClassFixture, IAs Assert.IsType(result.Id); Assert.NotEqual(default, result.Id); Assert.NotNull(result.Data); - Assert.Equal(15, ((JsonElement)result.Data["minComplexity"]).GetInt32()); + Assert.Equal(4, ((JsonElement)result.Data["minComplexity"]).GetInt32()); + Assert.Equal(128, ((JsonElement)result.Data["minLength"]).GetInt32()); Assert.True(((JsonElement)result.Data["requireLower"]).GetBoolean()); // Assert against the database values @@ -94,7 +96,7 @@ public class PoliciesControllerTests : IClassFixture, IAs Assert.NotNull(policy.Data); var data = policy.GetDataModel(); - var expectedData = new MasterPasswordPolicyData { MinComplexity = 15, RequireLower = true }; + var expectedData = new MasterPasswordPolicyData { MinComplexity = 4, MinLength = 128, RequireLower = true }; AssertHelper.AssertPropertyEqual(expectedData, data); } @@ -242,4 +244,46 @@ public class PoliciesControllerTests : IClassFixture, IAs // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + + [Fact] + public async Task Put_MasterPasswordPolicy_ExcessiveMinLength_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new PolicyUpdateRequestModel + { + Enabled = true, + Data = new Dictionary + { + { "minLength", 129 } + } + }; + + // Act + var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Put_MasterPasswordPolicy_ExcessiveMinComplexity_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new PolicyUpdateRequestModel + { + Enabled = true, + Data = new Dictionary + { + { "minComplexity", 5 } + } + }; + + // Act + var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } } diff --git a/test/Api.IntegrationTest/Api.IntegrationTest.csproj b/test/Api.IntegrationTest/Api.IntegrationTest.csproj index a9d7fd502e..153803ef21 100644 --- a/test/Api.IntegrationTest/Api.IntegrationTest.csproj +++ b/test/Api.IntegrationTest/Api.IntegrationTest.csproj @@ -1,6 +1,8 @@ false + + $(WarningsNotAsErrors);CA1304;CA1305 diff --git a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs index 09ec5b010f..d055418f3a 100644 --- a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs @@ -1,19 +1,28 @@ using System.Net; +using System.Text.Json; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.KeyManagement.Models.Requests; using Bit.Api.Models.Response; using Bit.Core; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Repositories; +using Bit.Core.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Identity; using NSubstitute; using Xunit; +using static Bit.Core.KeyManagement.Enums.SignatureAlgorithm; namespace Bit.Api.IntegrationTest.Controllers; @@ -21,6 +30,8 @@ public class AccountsControllerTest : IClassFixture, IAsy { private static readonly string _masterKeyWrappedUserKey = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + private static readonly string _mockEncryptedType7String = "7.AOs41Hd8OQiCPXjyJKCiDA=="; + private static readonly string _mockEncryptedType7WrappedSigningKey = "7.DRv74Kg1RSlFSam1MNFlGD=="; private static readonly string _masterPasswordHash = "master_password_hash"; private static readonly string _newMasterPasswordHash = "new_master_password_hash"; @@ -35,6 +46,11 @@ public class AccountsControllerTest : IClassFixture, IAsy private readonly IPushNotificationService _pushNotificationService; private readonly IFeatureService _featureService; private readonly IPasswordHasher _passwordHasher; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly IUserSignatureKeyPairRepository _userSignatureKeyPairRepository; + private readonly IEventRepository _eventRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; private string _ownerEmail = null!; @@ -49,6 +65,11 @@ public class AccountsControllerTest : IClassFixture, IAsy _pushNotificationService = _factory.GetService(); _featureService = _factory.GetService(); _passwordHasher = _factory.GetService>(); + _organizationRepository = _factory.GetService(); + _ssoConfigRepository = _factory.GetService(); + _userSignatureKeyPairRepository = _factory.GetService(); + _eventRepository = _factory.GetService(); + _organizationUserRepository = _factory.GetService(); } public async Task InitializeAsync() @@ -435,4 +456,531 @@ public class AccountsControllerTest : IClassFixture, IAsy message.Content = JsonContent.Create(requestModel); return await _client.SendAsync(message); } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V1_MasterPasswordDecryption_Success(string organizationSsoIdentifier) + { + // Arrange - Create organization and user + var ownerEmail = $"owner-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + ownerEmail: ownerEmail, + name: "Test Org V1"); + organization.UseSso = true; + organization.Identifier = organizationSsoIdentifier; + await _organizationRepository.ReplaceAsync(organization); + + await _ssoConfigRepository.CreateAsync(new SsoConfig + { + OrganizationId = organization.Id, + Enabled = true, + Data = JsonSerializer.Serialize(new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.MasterPassword, + }, JsonHelpers.CamelCase), + }); + + // Create user with password initially, so we can login + var userEmail = $"user-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(userEmail); + + // Add user to organization + var user = await _userRepository.GetByEmailAsync(userEmail); + Assert.NotNull(user); + await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, userEmail, + OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Invited); + + // Login as the user + await _loginHelper.LoginAsync(userEmail); + + // Remove the master password and keys to simulate newly registered SSO user + user.MasterPassword = null; + user.Key = null; + user.PrivateKey = null; + user.PublicKey = null; + await _userRepository.ReplaceAsync(user); + + // V1 (Obsolete) request format - to be removed with PM-27327 + var request = new + { + masterPasswordHash = _newMasterPasswordHash, + key = _masterKeyWrappedUserKey, + keys = new + { + publicKey = "v1-publicKey", + encryptedPrivateKey = "v1-encryptedPrivateKey" + }, + kdf = 0, // PBKDF2_SHA256 + kdfIterations = 600000, + kdfMemory = (int?)null, + kdfParallelism = (int?)null, + masterPasswordHint = "v1-integration-test-hint", + orgIdentifier = organization.Identifier + }; + + var jsonRequest = JsonSerializer.Serialize(request, JsonHelpers.CamelCase); + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password"); + message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json"); + var response = await _client.SendAsync(message); + + // Assert + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}"); + } + + // Verify user in database + var updatedUser = await _userRepository.GetByEmailAsync(userEmail); + Assert.NotNull(updatedUser); + Assert.Equal("v1-integration-test-hint", updatedUser.MasterPasswordHint); + + // Verify the master password is hashed and stored + Assert.NotNull(updatedUser.MasterPassword); + var verificationResult = _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword, _newMasterPasswordHash); + Assert.Equal(PasswordVerificationResult.Success, verificationResult); + + // Verify KDF settings + Assert.Equal(KdfType.PBKDF2_SHA256, updatedUser.Kdf); + Assert.Equal(600_000, updatedUser.KdfIterations); + Assert.Null(updatedUser.KdfMemory); + Assert.Null(updatedUser.KdfParallelism); + + // Verify timestamps are updated + Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1)); + Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1)); + + // Verify keys are set (V1 uses Keys property) + Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key); + Assert.Equal("v1-publicKey", updatedUser.PublicKey); + Assert.Equal("v1-encryptedPrivateKey", updatedUser.PrivateKey); + + // Verify User_ChangedPassword event was logged + var events = await _eventRepository.GetManyByUserAsync(updatedUser.Id, DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(1), new PageOptions { PageSize = 100 }); + Assert.NotNull(events); + Assert.Contains(events.Data, e => e.Type == EventType.User_ChangedPassword && e.UserId == updatedUser.Id); + + // Verify user was accepted into the organization + var orgUsers = await _organizationUserRepository.GetManyByUserAsync(updatedUser.Id); + var orgUser = orgUsers.FirstOrDefault(ou => ou.OrganizationId == organization.Id); + Assert.NotNull(orgUser); + Assert.Equal(OrganizationUserStatusType.Accepted, orgUser.Status); + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V2_MasterPasswordDecryption_Success(string organizationSsoIdentifier) + { + // Arrange - Create organization and user + var ownerEmail = $"owner-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + ownerEmail: ownerEmail, + name: "Test Org"); + organization.UseSso = true; + organization.Identifier = organizationSsoIdentifier; + await _organizationRepository.ReplaceAsync(organization); + + await _ssoConfigRepository.CreateAsync(new SsoConfig + { + OrganizationId = organization.Id, + Enabled = true, + Data = JsonSerializer.Serialize(new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.MasterPassword, + }, JsonHelpers.CamelCase), + }); + + // Create user with password initially, so we can login + var userEmail = $"user-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(userEmail); + + // Add user to organization + var user = await _userRepository.GetByEmailAsync(userEmail); + Assert.NotNull(user); + await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, userEmail, + OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Invited); + + // Login as the user + await _loginHelper.LoginAsync(userEmail); + + // Remove the master password and keys to simulate newly registered SSO user + user.MasterPassword = null; + user.Key = null; + user.PrivateKey = null; + user.PublicKey = null; + user.SignedPublicKey = null; + await _userRepository.ReplaceAsync(user); + + var jsonRequest = CreateV2SetPasswordRequestJson( + userEmail, + organization.Identifier, + "integration-test-hint", + includeAccountKeys: true); + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password"); + message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json"); + var response = await _client.SendAsync(message); + + // Assert + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}"); + } + + // Verify user in database + var updatedUser = await _userRepository.GetByEmailAsync(userEmail); + Assert.NotNull(updatedUser); + Assert.Equal("integration-test-hint", updatedUser.MasterPasswordHint); + + // Verify the master password is hashed and stored + Assert.NotNull(updatedUser.MasterPassword); + var verificationResult = _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword, _newMasterPasswordHash); + Assert.Equal(PasswordVerificationResult.Success, verificationResult); + + // Verify KDF settings + Assert.Equal(KdfType.PBKDF2_SHA256, updatedUser.Kdf); + Assert.Equal(600_000, updatedUser.KdfIterations); + Assert.Null(updatedUser.KdfMemory); + Assert.Null(updatedUser.KdfParallelism); + + // Verify timestamps are updated + Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1)); + Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1)); + + // Verify keys are set + Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key); + Assert.Equal("publicKey", updatedUser.PublicKey); + Assert.Equal(_mockEncryptedType7String, updatedUser.PrivateKey); + Assert.Equal("signedPublicKey", updatedUser.SignedPublicKey); + + // Verify security state + Assert.Equal(2, updatedUser.SecurityVersion); + Assert.Equal("v2", updatedUser.SecurityState); + + // Verify signature key pair data + var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(updatedUser.Id); + Assert.NotNull(signatureKeyPair); + Assert.Equal(Ed25519, signatureKeyPair.SignatureAlgorithm); + Assert.Equal(_mockEncryptedType7WrappedSigningKey, signatureKeyPair.WrappedSigningKey); + Assert.Equal("verifyingKey", signatureKeyPair.VerifyingKey); + + // Verify User_ChangedPassword event was logged + var events = await _eventRepository.GetManyByUserAsync(updatedUser.Id, DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(1), new PageOptions { PageSize = 100 }); + Assert.NotNull(events); + Assert.Contains(events.Data, e => e.Type == EventType.User_ChangedPassword && e.UserId == updatedUser.Id); + + // Verify user was accepted into the organization + var orgUsers = await _organizationUserRepository.GetManyByUserAsync(updatedUser.Id); + var orgUser = orgUsers.FirstOrDefault(ou => ou.OrganizationId == organization.Id); + Assert.NotNull(orgUser); + Assert.Equal(OrganizationUserStatusType.Accepted, orgUser.Status); + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V2_TDEDecryption_Success(string organizationSsoIdentifier) + { + // Arrange - Create organization with TDE + var ownerEmail = $"owner-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + ownerEmail: ownerEmail, + name: "Test Org TDE"); + organization.UseSso = true; + organization.Identifier = organizationSsoIdentifier; + await _organizationRepository.ReplaceAsync(organization); + + // Configure SSO for TDE (TrustedDeviceEncryption) + await _ssoConfigRepository.CreateAsync(new SsoConfig + { + OrganizationId = organization.Id, + Enabled = true, + Data = JsonSerializer.Serialize(new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption, + }, JsonHelpers.CamelCase), + }); + + // Create user with password initially, so we can login + var userEmail = $"user-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(userEmail); + + var user = await _userRepository.GetByEmailAsync(userEmail); + Assert.NotNull(user); + + // Add user to organization and confirm them (TDE users are confirmed, not invited) + await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, userEmail, + OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Confirmed); + + // Login as the user + await _loginHelper.LoginAsync(userEmail); + + // Set up TDE user with V2 account keys but no master password + // TDE users already have their account keys from device provisioning + user.MasterPassword = null; + user.Key = null; + user.PublicKey = "tde-publicKey"; + user.PrivateKey = _mockEncryptedType7String; + user.SignedPublicKey = "tde-signedPublicKey"; + user.SecurityVersion = 2; + user.SecurityState = "v2-tde"; + await _userRepository.ReplaceAsync(user); + + // Create signature key pair for TDE user + var signatureKeyPairData = new Core.KeyManagement.Models.Data.SignatureKeyPairData( + Ed25519, + _mockEncryptedType7WrappedSigningKey, + "tde-verifyingKey"); + var setSignatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(user.Id); + if (setSignatureKeyPair == null) + { + var newKeyPair = new Core.KeyManagement.Entities.UserSignatureKeyPair + { + UserId = user.Id, + SignatureAlgorithm = signatureKeyPairData.SignatureAlgorithm, + SigningKey = signatureKeyPairData.WrappedSigningKey, + VerifyingKey = signatureKeyPairData.VerifyingKey, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow + }; + newKeyPair.SetNewId(); + await _userSignatureKeyPairRepository.CreateAsync(newKeyPair); + } + + var jsonRequest = CreateV2SetPasswordRequestJson( + userEmail, + organization.Identifier, + "tde-test-hint", + includeAccountKeys: false); + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password"); + message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json"); + var response = await _client.SendAsync(message); + + // Assert + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}"); + } + + // Verify user in database + var updatedUser = await _userRepository.GetByEmailAsync(userEmail); + Assert.NotNull(updatedUser); + Assert.Equal("tde-test-hint", updatedUser.MasterPasswordHint); + + // Verify the master password is hashed and stored + Assert.NotNull(updatedUser.MasterPassword); + var verificationResult = _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword, _newMasterPasswordHash); + Assert.Equal(PasswordVerificationResult.Success, verificationResult); + + // Verify KDF settings + Assert.Equal(KdfType.PBKDF2_SHA256, updatedUser.Kdf); + Assert.Equal(600_000, updatedUser.KdfIterations); + Assert.Null(updatedUser.KdfMemory); + Assert.Null(updatedUser.KdfParallelism); + + // Verify timestamps are updated + Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1)); + Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1)); + + // Verify key is set + Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key); + + // Verify AccountKeys are preserved (TDE users already had V2 keys) + Assert.Equal("tde-publicKey", updatedUser.PublicKey); + Assert.Equal(_mockEncryptedType7String, updatedUser.PrivateKey); + Assert.Equal("tde-signedPublicKey", updatedUser.SignedPublicKey); + Assert.Equal(2, updatedUser.SecurityVersion); + Assert.Equal("v2-tde", updatedUser.SecurityState); + + // Verify signature key pair is preserved (TDE users already had signature keys) + var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(updatedUser.Id); + Assert.NotNull(signatureKeyPair); + Assert.Equal(Ed25519, signatureKeyPair.SignatureAlgorithm); + Assert.Equal(_mockEncryptedType7WrappedSigningKey, signatureKeyPair.WrappedSigningKey); + Assert.Equal("tde-verifyingKey", signatureKeyPair.VerifyingKey); + + // Verify User_ChangedPassword event was logged + var events = await _eventRepository.GetManyByUserAsync(updatedUser.Id, DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(1), new PageOptions { PageSize = 100 }); + Assert.NotNull(events); + Assert.Contains(events.Data, e => e.Type == EventType.User_ChangedPassword && e.UserId == updatedUser.Id); + + // Verify user remains confirmed in the organization + var orgUsers = await _organizationUserRepository.GetManyByUserAsync(updatedUser.Id); + var orgUser = orgUsers.FirstOrDefault(ou => ou.OrganizationId == organization.Id); + Assert.NotNull(orgUser); + Assert.Equal(OrganizationUserStatusType.Confirmed, orgUser.Status); + } + + [Fact] + public async Task PostSetPasswordAsync_V2_Unauthorized_ReturnsUnauthorized() + { + // Arrange - Don't login + var jsonRequest = CreateV2SetPasswordRequestJson( + "test@bitwarden.com", + "test-org-identifier", + "test-hint", + includeAccountKeys: true); + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password"); + message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json"); + var response = await _client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostSetPasswordAsync_V2_MismatchedKdfSettings_ReturnsBadRequest() + { + // Arrange + var email = $"kdf-mismatch-test-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + await _loginHelper.LoginAsync(email); + + // Test mismatched KDF settings (600000 vs 650000 iterations) + var request = new + { + masterPasswordAuthentication = new + { + kdf = new + { + kdfType = 0, + iterations = 600000 + }, + masterPasswordAuthenticationHash = _newMasterPasswordHash, + salt = email + }, + masterPasswordUnlock = new + { + kdf = new + { + kdfType = 0, + iterations = 650000 // Different from authentication KDF + }, + masterKeyWrappedUserKey = _masterKeyWrappedUserKey, + salt = email + }, + accountKeys = new + { + userKeyEncryptedAccountPrivateKey = "7.AOs41Hd8OQiCPXjyJKCiDA==", + accountPublicKey = "public-key" + }, + orgIdentifier = "test-org-identifier" + }; + + var jsonRequest = JsonSerializer.Serialize(request, JsonHelpers.CamelCase); + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password"); + message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json"); + var response = await _client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 1, null, null)] + [InlineData(KdfType.Argon2id, 4, null, 5)] + [InlineData(KdfType.Argon2id, 4, 65, null)] + public async Task PostSetPasswordAsync_V2_InvalidKdfSettings_ReturnsBadRequest( + KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + // Arrange + var email = $"invalid-kdf-test-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + await _loginHelper.LoginAsync(email); + + var jsonRequest = CreateV2SetPasswordRequestJson( + email, + "test-org-identifier", + "test-hint", + includeAccountKeys: true, + kdfType: kdf, + kdfIterations: kdfIterations, + kdfMemory: kdfMemory, + kdfParallelism: kdfParallelism); + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password"); + message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json"); + var response = await _client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private static string CreateV2SetPasswordRequestJson( + string userEmail, + string orgIdentifier, + string hint, + bool includeAccountKeys = true, + KdfType? kdfType = null, + int? kdfIterations = null, + int? kdfMemory = null, + int? kdfParallelism = null) + { + var kdf = new + { + kdfType = (int)(kdfType ?? KdfType.PBKDF2_SHA256), + iterations = kdfIterations ?? 600000, + memory = kdfMemory, + parallelism = kdfParallelism + }; + + var request = new + { + masterPasswordAuthentication = new + { + kdf, + masterPasswordAuthenticationHash = _newMasterPasswordHash, + salt = userEmail + }, + masterPasswordUnlock = new + { + kdf, + masterKeyWrappedUserKey = _masterKeyWrappedUserKey, + salt = userEmail + }, + accountKeys = includeAccountKeys ? new + { + accountPublicKey = "publicKey", + userKeyEncryptedAccountPrivateKey = _mockEncryptedType7String, + publicKeyEncryptionKeyPair = new + { + publicKey = "publicKey", + wrappedPrivateKey = _mockEncryptedType7String, + signedPublicKey = "signedPublicKey" + }, + signatureKeyPair = new + { + signatureAlgorithm = "ed25519", + wrappedSigningKey = _mockEncryptedType7WrappedSigningKey, + verifyingKey = "verifyingKey" + }, + securityState = new + { + securityVersion = 2, + securityState = "v2" + } + } : null, + masterPasswordHint = hint, + orgIdentifier + }; + + return JsonSerializer.Serialize(request, JsonHelpers.CamelCase); + } } diff --git a/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs b/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs index a729abb849..3551ed4efa 100644 --- a/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs +++ b/test/Api.IntegrationTest/Controllers/Public/CollectionsControllerTests.cs @@ -6,6 +6,7 @@ using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.Entities; 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.Platform.Push; @@ -114,4 +115,64 @@ public class CollectionsControllerTests : IClassFixture, Assert.NotEmpty(result.Item2.Groups); Assert.NotEmpty(result.Item2.Users); } + + [Fact] + public async Task List_ExcludesDefaultUserCollections_IncludesGroupsAndUsers() + { + // Arrange + var collectionRepository = _factory.GetService(); + var groupRepository = _factory.GetService(); + + var defaultCollection = new Collection + { + OrganizationId = _organization.Id, + Name = "My Items", + Type = CollectionType.DefaultUserCollection + }; + await collectionRepository.CreateAsync(defaultCollection, null, null); + + var group = await groupRepository.CreateAsync(new Group + { + OrganizationId = _organization.Id, + Name = "Test Group", + ExternalId = $"test-group-{Guid.NewGuid()}", + }); + + var (_, user) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, + _organization.Id, + OrganizationUserType.User); + + var sharedCollection = await OrganizationTestHelpers.CreateCollectionAsync( + _factory, + _organization.Id, + "Shared Collection with Access", + externalId: "shared-collection-with-access", + groups: + [ + new CollectionAccessSelection { Id = group.Id, ReadOnly = false, HidePasswords = false, Manage = true } + ], + users: + [ + new CollectionAccessSelection { Id = user.Id, ReadOnly = true, HidePasswords = true, Manage = false } + ]); + + // Act + var response = await _client.GetFromJsonAsync>("public/collections"); + + // Assert + Assert.NotNull(response); + + Assert.DoesNotContain(response.Data, c => c.Id == defaultCollection.Id); + + var collectionResponse = response.Data.First(c => c.Id == sharedCollection.Id); + Assert.NotNull(collectionResponse.Groups); + Assert.Single(collectionResponse.Groups); + + var groupResponse = collectionResponse.Groups.First(); + Assert.Equal(group.Id, groupResponse.Id); + Assert.False(groupResponse.ReadOnly); + Assert.False(groupResponse.HidePasswords); + Assert.True(groupResponse.Manage); + } } diff --git a/test/Api.Test/AdminConsole/Authorization/Requirements/MemberRequirementTests.cs b/test/Api.Test/AdminConsole/Authorization/Requirements/MemberRequirementTests.cs new file mode 100644 index 0000000000..bf06e7d576 --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/Requirements/MemberRequirementTests.cs @@ -0,0 +1,49 @@ +using Bit.Api.AdminConsole.Authorization.Requirements; +using Bit.Core.Context; +using Bit.Core.Enums; +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 MemberRequirementTests +{ + [Theory] + [CurrentContextOrganizationCustomize] + [BitAutoData(OrganizationUserType.Owner)] + [BitAutoData(OrganizationUserType.Admin)] + [BitAutoData(OrganizationUserType.User)] + [BitAutoData(OrganizationUserType.Custom)] + public async Task AuthorizeAsync_WhenUserIsOrganizationMember_ThenRequestShouldBeAuthorized( + OrganizationUserType type, + CurrentContextOrganization organization, + SutProvider sutProvider) + { + organization.Type = type; + + var actual = await sutProvider.Sut.AuthorizeAsync(organization, () => Task.FromResult(false)); + + Assert.True(actual); + } + + [Theory, BitAutoData] + public async Task AuthorizeAsync_WhenUserIsNotOrganizationMember_ThenRequestShouldBeDenied( + SutProvider sutProvider) + { + var actual = await sutProvider.Sut.AuthorizeAsync(null, () => Task.FromResult(false)); + + Assert.False(actual); + } + + [Theory, BitAutoData] + public async Task AuthorizeAsync_WhenUserIsProviderButNotMember_ThenRequestShouldBeDenied( + SutProvider sutProvider) + { + var actual = await sutProvider.Sut.AuthorizeAsync(null, () => Task.FromResult(true)); + + Assert.False(actual); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index 43f0123a3f..68a63bf579 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -14,7 +14,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Repositories; @@ -30,6 +29,7 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -137,23 +137,20 @@ public class OrganizationUsersControllerTests [Theory] [BitAutoData] public async Task Accept_WhenOrganizationUsePoliciesIsEnabledAndResetPolicyIsEnabled_ShouldHandleResetPassword(Guid orgId, Guid orgUserId, - OrganizationUserAcceptRequestModel model, User user, SutProvider sutProvider) + OrganizationUserAcceptRequestModel model, User user, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, + SutProvider sutProvider) { // Arrange var applicationCacheService = sutProvider.GetDependency(); applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = true }); - var policy = new Policy - { - Enabled = true, - Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }), - }; + policy.Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }); var userService = sutProvider.GetDependency(); userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); - - var policyRepository = sutProvider.GetDependency(); - policyRepository.GetByOrganizationIdTypeAsync(orgId, + var policyQuery = sutProvider.GetDependency(); + policyQuery.RunAsync(orgId, PolicyType.ResetPassword).Returns(policy); // Act @@ -167,29 +164,27 @@ public class OrganizationUsersControllerTests await userService.Received(1).GetUserByPrincipalAsync(default); await applicationCacheService.Received(1).GetOrganizationAbilityAsync(orgId); - await policyRepository.Received(1).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + await policyQuery.Received(1).RunAsync(orgId, PolicyType.ResetPassword); } [Theory] [BitAutoData] public async Task Accept_WhenOrganizationUsePoliciesIsDisabled_ShouldNotHandleResetPassword(Guid orgId, Guid orgUserId, - OrganizationUserAcceptRequestModel model, User user, SutProvider sutProvider) + OrganizationUserAcceptRequestModel model, User user, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, + SutProvider sutProvider) { // Arrange var applicationCacheService = sutProvider.GetDependency(); applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = false }); - var policy = new Policy - { - Enabled = true, - Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }), - }; + policy.Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }); var userService = sutProvider.GetDependency(); userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); - var policyRepository = sutProvider.GetDependency(); - policyRepository.GetByOrganizationIdTypeAsync(orgId, + var policyQuery = sutProvider.GetDependency(); + policyQuery.RunAsync(orgId, PolicyType.ResetPassword).Returns(policy); // Act @@ -202,7 +197,7 @@ public class OrganizationUsersControllerTests await sutProvider.GetDependency().Received(0) .UpdateUserResetPasswordEnrollmentAsync(orgId, user.Id, model.ResetPasswordKey, user.Id); - await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + await policyQuery.Received(0).RunAsync(orgId, PolicyType.ResetPassword); await applicationCacheService.Received(1).GetOrganizationAbilityAsync(orgId); } @@ -383,7 +378,7 @@ public class OrganizationUsersControllerTests var policyRequirementQuery = sutProvider.GetDependency(); - var policyRepository = sutProvider.GetDependency(); + var policyQuery = sutProvider.GetDependency(); var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [orgId] }; @@ -400,7 +395,7 @@ public class OrganizationUsersControllerTests await userService.Received(1).GetUserByPrincipalAsync(default); await applicationCacheService.Received(0).GetOrganizationAbilityAsync(orgId); - await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + await policyQuery.Received(0).RunAsync(orgId, PolicyType.ResetPassword); await policyRequirementQuery.Received(1).GetAsync(user.Id); Assert.True(policyRequirement.AutoEnrollEnabled(orgId)); } @@ -425,7 +420,7 @@ public class OrganizationUsersControllerTests var userService = sutProvider.GetDependency(); userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); - var policyRepository = sutProvider.GetDependency(); + var policyQuery = sutProvider.GetDependency(); var policyRequirementQuery = sutProvider.GetDependency(); @@ -445,7 +440,7 @@ public class OrganizationUsersControllerTests await userService.Received(1).GetUserByPrincipalAsync(default); await applicationCacheService.Received(0).GetOrganizationAbilityAsync(orgId); - await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + await policyQuery.Received(0).RunAsync(orgId, PolicyType.ResetPassword); await policyRequirementQuery.Received(1).GetAsync(user.Id); Assert.Equal("Master Password reset is required, but not provided.", exception.Message); @@ -734,7 +729,7 @@ public class OrganizationUsersControllerTests [Theory] [BitAutoData] - public async Task BulkReinvite_WhenFeatureFlagEnabled_UsesBulkResendOrganizationInvitesCommand( + public async Task BulkReinvite_UsesBulkResendOrganizationInvitesCommand( Guid organizationId, OrganizationUserBulkRequestModel bulkRequestModel, List organizationUsers, @@ -744,9 +739,6 @@ public class OrganizationUsersControllerTests // Arrange sutProvider.GetDependency().ManageUsers(organizationId).Returns(true); sutProvider.GetDependency().GetProperUserId(Arg.Any()).Returns(userId); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud) - .Returns(true); var expectedResults = organizationUsers.Select(u => Tuple.Create(u, "")).ToList(); sutProvider.GetDependency() @@ -763,36 +755,4 @@ public class OrganizationUsersControllerTests .Received(1) .BulkResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids); } - - [Theory] - [BitAutoData] - public async Task BulkReinvite_WhenFeatureFlagDisabled_UsesLegacyOrganizationService( - Guid organizationId, - OrganizationUserBulkRequestModel bulkRequestModel, - List organizationUsers, - Guid userId, - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().ManageUsers(organizationId).Returns(true); - sutProvider.GetDependency().GetProperUserId(Arg.Any()).Returns(userId); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud) - .Returns(false); - - var expectedResults = organizationUsers.Select(u => Tuple.Create(u, "")).ToList(); - sutProvider.GetDependency() - .ResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids) - .Returns(expectedResults); - - // Act - var response = await sutProvider.Sut.BulkReinvite(organizationId, bulkRequestModel); - - // Assert - Assert.Equal(organizationUsers.Count, response.Data.Count()); - - await sutProvider.GetDependency() - .Received(1) - .ResendInvitesAsync(organizationId, userId, bulkRequestModel.Ids); - } } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index d87f035a13..cc09e9e0a0 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -7,6 +7,7 @@ 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.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -25,6 +26,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.Billing.Mocks; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; using Bit.Test.Common.AutoFixture; @@ -200,28 +202,21 @@ public class OrganizationsControllerTests SutProvider sutProvider, User user, Organization organization, - OrganizationUser organizationUser) + OrganizationUser organizationUser, + [Policy(PolicyType.ResetPassword, data: "{\"AutoEnrollEnabled\": true}")] PolicyStatus policy) { - var policy = new Policy - { - Type = PolicyType.ResetPassword, - Enabled = true, - Data = "{\"AutoEnrollEnabled\": true}", - OrganizationId = organization.Id - }; - sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); sutProvider.GetDependency().GetByIdentifierAsync(organization.Id.ToString()).Returns(organization); sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false); sutProvider.GetDependency().GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser); - sutProvider.GetDependency().GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword).Returns(policy); + sutProvider.GetDependency().RunAsync(organization.Id, PolicyType.ResetPassword).Returns(policy); var result = await sutProvider.Sut.GetAutoEnrollStatus(organization.Id.ToString()); await sutProvider.GetDependency().Received(1).GetUserByPrincipalAsync(Arg.Any()); await sutProvider.GetDependency().Received(1).GetByIdentifierAsync(organization.Id.ToString()); await sutProvider.GetDependency().Received(0).GetAsync(user.Id); - await sutProvider.GetDependency().Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword); + await sutProvider.GetDependency().Received(1).RunAsync(organization.Id, PolicyType.ResetPassword); Assert.True(result.ResetPasswordEnabled); } diff --git a/test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyDetailResponsesTests.cs b/test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyStatusResponsesTests.cs similarity index 62% rename from test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyDetailResponsesTests.cs rename to test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyStatusResponsesTests.cs index 9b863091db..46c6d64bdd 100644 --- a/test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyDetailResponsesTests.cs +++ b/test/Api.Test/AdminConsole/Models/Response/Helpers/PolicyStatusResponsesTests.cs @@ -1,14 +1,13 @@ -using AutoFixture; -using Bit.Api.AdminConsole.Models.Response.Helpers; -using Bit.Core.AdminConsole.Entities; +using Bit.Api.AdminConsole.Models.Response.Helpers; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using NSubstitute; using Xunit; namespace Bit.Api.Test.AdminConsole.Models.Response.Helpers; -public class PolicyDetailResponsesTests +public class PolicyStatusResponsesTests { [Theory] [InlineData(true, false)] @@ -17,19 +16,13 @@ public class PolicyDetailResponsesTests bool policyEnabled, bool expectedCanToggle) { - var fixture = new Fixture(); - - var policy = fixture.Build() - .Without(p => p.Data) - .With(p => p.Type, PolicyType.SingleOrg) - .With(p => p.Enabled, policyEnabled) - .Create(); + var policy = new PolicyStatus(Guid.NewGuid(), PolicyType.SingleOrg) { Enabled = policyEnabled }; var querySub = Substitute.For(); querySub.HasVerifiedDomainsAsync(policy.OrganizationId) .Returns(true); - var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub); + var result = await policy.GetSingleOrgPolicyStatusResponseAsync(querySub); Assert.Equal(expectedCanToggle, result.CanToggleState); } @@ -37,18 +30,13 @@ public class PolicyDetailResponsesTests [Fact] public async Task GetSingleOrgPolicyDetailResponseAsync_WhenIsNotSingleOrgType_ThenShouldThrowArgumentException() { - var fixture = new Fixture(); - - var policy = fixture.Build() - .Without(p => p.Data) - .With(p => p.Type, PolicyType.TwoFactorAuthentication) - .Create(); + var policy = new PolicyStatus(Guid.NewGuid(), PolicyType.TwoFactorAuthentication); var querySub = Substitute.For(); querySub.HasVerifiedDomainsAsync(policy.OrganizationId) .Returns(true); - var action = async () => await policy.GetSingleOrgPolicyDetailResponseAsync(querySub); + var action = async () => await policy.GetSingleOrgPolicyStatusResponseAsync(querySub); await Assert.ThrowsAsync("policy", action); } @@ -56,18 +44,13 @@ public class PolicyDetailResponsesTests [Fact] public async Task GetSingleOrgPolicyDetailResponseAsync_WhenIsSingleOrgTypeAndDoesNotHaveVerifiedDomains_ThenShouldBeAbleToToggle() { - var fixture = new Fixture(); - - var policy = fixture.Build() - .Without(p => p.Data) - .With(p => p.Type, PolicyType.SingleOrg) - .Create(); + var policy = new PolicyStatus(Guid.NewGuid(), PolicyType.SingleOrg); var querySub = Substitute.For(); querySub.HasVerifiedDomainsAsync(policy.OrganizationId) .Returns(false); - var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub); + var result = await policy.GetSingleOrgPolicyStatusResponseAsync(querySub); Assert.True(result.CanToggleState); } diff --git a/test/Api.Test/AdminConsole/Models/Response/ProfileOrganizationResponseModelTests.cs b/test/Api.Test/AdminConsole/Models/Response/ProfileOrganizationResponseModelTests.cs index 30b0ccc272..56190947e5 100644 --- a/test/Api.Test/AdminConsole/Models/Response/ProfileOrganizationResponseModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Response/ProfileOrganizationResponseModelTests.cs @@ -49,6 +49,7 @@ public class ProfileOrganizationResponseModelTests UseCustomPermissions = organization.UseCustomPermissions, UseRiskInsights = organization.UseRiskInsights, UsePhishingBlocker = organization.UsePhishingBlocker, + UseDisableSMAdsForUsers = organization.UseDisableSmAdsForUsers, UseOrganizationDomains = organization.UseOrganizationDomains, UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies, UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation, diff --git a/test/Api.Test/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModelTests.cs b/test/Api.Test/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModelTests.cs index 1757f9d983..2cb0f12738 100644 --- a/test/Api.Test/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModelTests.cs @@ -46,6 +46,7 @@ public class ProfileProviderOrganizationResponseModelTests UseCustomPermissions = organization.UseCustomPermissions, UseRiskInsights = organization.UseRiskInsights, UsePhishingBlocker = organization.UsePhishingBlocker, + UseDisableSMAdsForUsers = organization.UseDisableSmAdsForUsers, UseOrganizationDomains = organization.UseOrganizationDomains, UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies, UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation, diff --git a/test/Api.Test/Api.Test.csproj b/test/Api.Test/Api.Test.csproj index fb75246d4f..da9cdcff06 100644 --- a/test/Api.Test/Api.Test.csproj +++ b/test/Api.Test/Api.Test.csproj @@ -2,6 +2,8 @@ false + + $(WarningsNotAsErrors);CA1304 diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 300a4d823d..6cddd341d5 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -1,14 +1,17 @@ using System.Security.Claims; using Bit.Api.Auth.Controllers; using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.KeyManagement.Models.Requests; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Auth.Models.Data; 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.Enums; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Kdf; using Bit.Core.KeyManagement.Models.Api.Request; @@ -33,7 +36,9 @@ public class AccountsControllerTests : IDisposable private readonly IProviderUserRepository _providerUserRepository; private readonly IPolicyService _policyService; private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; + private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly IFeatureService _featureService; private readonly IUserAccountKeysQuery _userAccountKeysQuery; @@ -49,7 +54,9 @@ public class AccountsControllerTests : IDisposable _providerUserRepository = Substitute.For(); _policyService = Substitute.For(); _setInitialMasterPasswordCommand = Substitute.For(); + _setInitialMasterPasswordCommandV1 = Substitute.For(); _twoFactorIsEnabledQuery = Substitute.For(); + _tdeSetPasswordCommand = Substitute.For(); _tdeOffboardingPasswordCommand = Substitute.For(); _featureService = Substitute.For(); _userAccountKeysQuery = Substitute.For(); @@ -64,6 +71,8 @@ public class AccountsControllerTests : IDisposable _userService, _policyService, _setInitialMasterPasswordCommand, + _setInitialMasterPasswordCommandV1, + _tdeSetPasswordCommand, _tdeOffboardingPasswordCommand, _twoFactorIsEnabledQuery, _featureService, @@ -379,13 +388,13 @@ public class AccountsControllerTests : IDisposable [BitAutoData(true, null, "newPublicKey", false)] // reject overwriting existing keys [BitAutoData(true, "newPrivateKey", "newPublicKey", false)] - public async Task PostSetPasswordAsync_WhenUserExistsAndSettingPasswordSucceeds_ShouldHandleKeysCorrectlyAndReturn( + public async Task PostSetPasswordAsync_V1_WhenUserExistsAndSettingPasswordSucceeds_ShouldHandleKeysCorrectlyAndReturn( bool hasExistingKeys, string requestPrivateKey, string requestPublicKey, bool shouldSucceed, User user, - SetPasswordRequestModel setPasswordRequestModel) + SetInitialPasswordRequestModel setInitialPasswordRequestModel) { // Arrange const string existingPublicKey = "existingPublicKey"; @@ -402,13 +411,15 @@ public class AccountsControllerTests : IDisposable user.PrivateKey = null; } + UpdateSetInitialPasswordRequestModelToV1(setInitialPasswordRequestModel); + if (requestPrivateKey == null && requestPublicKey == null) { - setPasswordRequestModel.Keys = null; + setInitialPasswordRequestModel.Keys = null; } else { - setPasswordRequestModel.Keys = new KeysRequestModel + setInitialPasswordRequestModel.Keys = new KeysRequestModel { EncryptedPrivateKey = requestPrivateKey, PublicKey = requestPublicKey @@ -416,44 +427,44 @@ public class AccountsControllerTests : IDisposable } _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); - _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync( + _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync( user, - setPasswordRequestModel.MasterPasswordHash, - setPasswordRequestModel.Key, - setPasswordRequestModel.OrgIdentifier) + setInitialPasswordRequestModel.MasterPasswordHash, + setInitialPasswordRequestModel.Key, + setInitialPasswordRequestModel.OrgIdentifier) .Returns(Task.FromResult(IdentityResult.Success)); // Act if (shouldSucceed) { - await _sut.PostSetPasswordAsync(setPasswordRequestModel); + await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel); // Assert - await _setInitialMasterPasswordCommand.Received(1) + await _setInitialMasterPasswordCommandV1.Received(1) .SetInitialMasterPasswordAsync( Arg.Is(u => u == user), - Arg.Is(s => s == setPasswordRequestModel.MasterPasswordHash), - Arg.Is(s => s == setPasswordRequestModel.Key), - Arg.Is(s => s == setPasswordRequestModel.OrgIdentifier)); + Arg.Is(s => s == setInitialPasswordRequestModel.MasterPasswordHash), + Arg.Is(s => s == setInitialPasswordRequestModel.Key), + Arg.Is(s => s == setInitialPasswordRequestModel.OrgIdentifier)); // Additional Assertions for User object modifications - Assert.Equal(setPasswordRequestModel.MasterPasswordHint, user.MasterPasswordHint); - Assert.Equal(setPasswordRequestModel.Kdf, user.Kdf); - Assert.Equal(setPasswordRequestModel.KdfIterations, user.KdfIterations); - Assert.Equal(setPasswordRequestModel.KdfMemory, user.KdfMemory); - Assert.Equal(setPasswordRequestModel.KdfParallelism, user.KdfParallelism); - Assert.Equal(setPasswordRequestModel.Key, user.Key); + Assert.Equal(setInitialPasswordRequestModel.MasterPasswordHint, user.MasterPasswordHint); + Assert.Equal(setInitialPasswordRequestModel.Kdf, user.Kdf); + Assert.Equal(setInitialPasswordRequestModel.KdfIterations, user.KdfIterations); + Assert.Equal(setInitialPasswordRequestModel.KdfMemory, user.KdfMemory); + Assert.Equal(setInitialPasswordRequestModel.KdfParallelism, user.KdfParallelism); + Assert.Equal(setInitialPasswordRequestModel.Key, user.Key); } else { - await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setPasswordRequestModel)); + await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel)); } } [Theory] [BitAutoData] - public async Task PostSetPasswordAsync_WhenUserExistsAndHasKeysAndKeysAreUpdated_ShouldThrowAsync( + public async Task PostSetPasswordAsync_V1_WhenUserExistsAndHasKeysAndKeysAreUpdated_ShouldThrowAsync( User user, - SetPasswordRequestModel setPasswordRequestModel) + SetInitialPasswordRequestModel setInitialPasswordRequestModel) { // Arrange const string existingPublicKey = "existingPublicKey"; @@ -465,47 +476,52 @@ public class AccountsControllerTests : IDisposable user.PublicKey = existingPublicKey; user.PrivateKey = existingEncryptedPrivateKey; - setPasswordRequestModel.Keys = new KeysRequestModel() + UpdateSetInitialPasswordRequestModelToV1(setInitialPasswordRequestModel); + + setInitialPasswordRequestModel.Keys = new KeysRequestModel() { PublicKey = newPublicKey, EncryptedPrivateKey = newEncryptedPrivateKey }; _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); - _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync( + _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync( user, - setPasswordRequestModel.MasterPasswordHash, - setPasswordRequestModel.Key, - setPasswordRequestModel.OrgIdentifier) + setInitialPasswordRequestModel.MasterPasswordHash, + setInitialPasswordRequestModel.Key, + setInitialPasswordRequestModel.OrgIdentifier) .Returns(Task.FromResult(IdentityResult.Success)); // Act & Assert - await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setPasswordRequestModel)); + await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel)); } [Theory] [BitAutoData] - public async Task PostSetPasswordAsync_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException( - SetPasswordRequestModel setPasswordRequestModel) + public async Task PostSetPasswordAsync_V1_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException( + SetInitialPasswordRequestModel setInitialPasswordRequestModel) { + UpdateSetInitialPasswordRequestModelToV1(setInitialPasswordRequestModel); + // Arrange _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult((User)null)); // Act & Assert - await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setPasswordRequestModel)); + await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel)); } [Theory] [BitAutoData] - public async Task PostSetPasswordAsync_WhenSettingPasswordFails_ShouldThrowBadRequestException( + public async Task PostSetPasswordAsync_V1_WhenSettingPasswordFails_ShouldThrowBadRequestException( User user, - SetPasswordRequestModel model) + SetInitialPasswordRequestModel model) { + UpdateSetInitialPasswordRequestModelToV1(model); model.Keys = null; // Arrange _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); - _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Failed(new IdentityError { Description = "Some Error" }))); // Act & Assert @@ -845,5 +861,139 @@ public class AccountsControllerTests : IDisposable Assert.NotNull(result); Assert.Equal("keys", result.Object); } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V2_WhenUserExistsAndSettingPasswordSucceeds_ShouldSetInitialMasterPassword( + User user, + SetInitialPasswordRequestModel setInitialPasswordRequestModel) + { + // Arrange + UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel); + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel); + + // Assert + await _setInitialMasterPasswordCommand.Received(1) + .SetInitialMasterPasswordAsync( + Arg.Is(u => u == user), + Arg.Is(d => + d.MasterPasswordAuthentication != null && + d.MasterPasswordUnlock != null && + d.AccountKeys != null && + d.OrgSsoIdentifier == setInitialPasswordRequestModel.OrgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V2_WithTdeSetPassword_ShouldCallTdeSetPasswordCommand( + User user, + SetInitialPasswordRequestModel setInitialPasswordRequestModel) + { + // Arrange + UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel, includeTdeSetPassword: true); + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + _tdeSetPasswordCommand.SetMasterPasswordAsync(user, Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel); + + // Assert + await _tdeSetPasswordCommand.Received(1) + .SetMasterPasswordAsync( + Arg.Is(u => u == user), + Arg.Is(d => + d.MasterPasswordAuthentication != null && + d.MasterPasswordUnlock != null && + d.AccountKeys == null && + d.OrgSsoIdentifier == setInitialPasswordRequestModel.OrgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V2_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException( + SetInitialPasswordRequestModel setInitialPasswordRequestModel) + { + // Arrange + UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel); + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult((User)null)); + + // Act & Assert + await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel)); + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V2_WhenSettingPasswordFails_ShouldThrowException( + User user, + SetInitialPasswordRequestModel setInitialPasswordRequestModel) + { + // Arrange + UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel); + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, Arg.Any()) + .Returns(Task.FromException(new Exception("Setting password failed"))); + + // Act & Assert + await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel)); + } + + private void UpdateSetInitialPasswordRequestModelToV1(SetInitialPasswordRequestModel model) + { + model.MasterPasswordAuthentication = null; + model.MasterPasswordUnlock = null; + model.AccountKeys = null; + } + + private void UpdateSetInitialPasswordRequestModelToV2(SetInitialPasswordRequestModel model, bool includeTdeSetPassword = false) + { + var kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }; + + model.MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }; + + model.MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + }; + + if (includeTdeSetPassword) + { + // TDE set password does not include AccountKeys + model.AccountKeys = null; + } + else + { + model.AccountKeys = new AccountKeysRequestModel + { + UserKeyEncryptedAccountPrivateKey = "privateKey", + AccountPublicKey = "publicKey" + }; + } + + // Clear V1 properties + model.MasterPasswordHash = null; + model.Key = null; + model.Keys = null; + model.Kdf = null; + model.KdfIterations = null; + model.KdfMemory = null; + model.KdfParallelism = null; + } } diff --git a/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs new file mode 100644 index 0000000000..ce8ba1811e --- /dev/null +++ b/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs @@ -0,0 +1,682 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.KeyManagement.Models.Requests; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.Test.Auth.Models.Request.Accounts; + +public class SetInitialPasswordRequestModelTests +{ + #region V2 Validation Tests + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [InlineData(KdfType.Argon2id, 3, 64, 4)] + public void Validate_V2Request_WithMatchingKdf_ReturnsNoErrors(KdfType kdfType, int iterations, int? memory, int? parallelism) + { + // Arrange + var kdf = new KdfRequestModel + { + KdfType = kdfType, + Iterations = iterations, + Memory = memory, + Parallelism = parallelism + }; + + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = "orgIdentifier", + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + }, + AccountKeys = new AccountKeysRequestModel + { + UserKeyEncryptedAccountPrivateKey = "privateKey", + AccountPublicKey = "publicKey" + } + }; + + // Act + var result = model.Validate(new ValidationContext(model)); + + // Assert + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public void Validate_V2Request_WithMismatchedKdfSettings_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 650000 // Different iterations + }, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Single(result); + Assert.Contains("KDF settings must be equal", result[0].ErrorMessage); + var memberNames = result[0].MemberNames.ToList(); + Assert.Equal(2, memberNames.Count); + Assert.Contains("MasterPasswordAuthentication.Kdf", memberNames); + Assert.Contains("MasterPasswordUnlock.Kdf", memberNames); + } + + [Theory] + [BitAutoData] + public void Validate_V2Request_WithInvalidAuthenticationKdf_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 1 // Too low + }; + + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.NotEmpty(result); + Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("KDF iterations must be between")); + } + + #endregion + + #region V1 Validation Tests (Obsolete) + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithMissingMasterPasswordHash_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + Key = "key", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 600000 + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Contains(result, r => r.ErrorMessage.Contains("MasterPasswordHash must be supplied")); + } + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithMissingKey_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 600000 + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Contains(result, r => r.ErrorMessage.Contains("Key must be supplied")); + } + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithMissingKdf_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Key = "key", + KdfIterations = 600000 + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("Kdf must be supplied")); + } + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithMissingKdfIterations_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Key = "key", + Kdf = KdfType.PBKDF2_SHA256 + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("KdfIterations must be supplied")); + } + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithArgon2idAndMissingMemory_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Key = "key", + Kdf = KdfType.Argon2id, + KdfIterations = 3, + KdfParallelism = 4 + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Contains(result, r => r.ErrorMessage.Contains("KdfMemory must be supplied when Kdf is Argon2id")); + } + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithArgon2idAndMissingParallelism_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Key = "key", + Kdf = KdfType.Argon2id, + KdfIterations = 3, + KdfMemory = 64 + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Contains(result, r => r.ErrorMessage.Contains("KdfParallelism must be supplied when Kdf is Argon2id")); + } + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithInvalidKdfSettings_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Key = "key", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 5000 // Too low + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.NotEmpty(result); + Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("KDF iterations must be between")); + } + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [InlineData(KdfType.Argon2id, 3, 64, 4)] + public void Validate_V1Request_WithValidSettings_ReturnsNoErrors(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = "orgIdentifier", + MasterPasswordHash = "hash", + Key = "key", + Kdf = kdfType, + KdfIterations = kdfIterations, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism + }; + + // Act + var result = model.Validate(new ValidationContext(model)); + + // Assert + Assert.Empty(result); + } + + #endregion + + #region IsV2Request Tests + + [Theory] + [BitAutoData] + public void IsV2Request_WithV2Properties_ReturnsTrue(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + // Act + var result = model.IsV2Request(); + + // Assert + Assert.True(result); + } + + [Theory] + [BitAutoData] + public void IsV2Request_WithoutMasterPasswordAuthentication_ReturnsFalse(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + // Act + var result = model.IsV2Request(); + + // Assert + Assert.False(result); + } + + [Theory] + [BitAutoData] + public void IsV2Request_WithoutMasterPasswordUnlock_ReturnsFalse(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + } + }; + + // Act + var result = model.IsV2Request(); + + // Assert + Assert.False(result); + } + + [Theory] + [BitAutoData] + public void IsV2Request_WithV1Properties_ReturnsFalse(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Key = "key", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 600000 + }; + + // Act + var result = model.IsV2Request(); + + // Assert + Assert.False(result); + } + + #endregion + + #region IsTdeSetPasswordRequest Tests + + [Theory] + [BitAutoData] + public void IsTdeSetPasswordRequest_WithNullAccountKeys_ReturnsTrue(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + }, + AccountKeys = null + }; + + // Act + var result = model.IsTdeSetPasswordRequest(); + + // Assert + Assert.True(result); + } + + [Theory] + [BitAutoData] + public void IsTdeSetPasswordRequest_WithAccountKeys_ReturnsFalse(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + }, + AccountKeys = new AccountKeysRequestModel + { + UserKeyEncryptedAccountPrivateKey = "privateKey", + AccountPublicKey = "publicKey" + } + }; + + // Act + var result = model.IsTdeSetPasswordRequest(); + + // Assert + Assert.False(result); + } + + #endregion + + #region ToUser Tests (Obsolete) + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [InlineData(KdfType.Argon2id, 3, 64, 4)] + public void ToUser_WithKeys_MapsPropertiesCorrectly(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + // Arrange + var existingUser = new User(); + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = "orgIdentifier", + MasterPasswordHash = "hash", + MasterPasswordHint = "hint", + Key = "key", + Kdf = kdfType, + KdfIterations = kdfIterations, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism, + Keys = new KeysRequestModel + { + PublicKey = "publicKey", + EncryptedPrivateKey = "encryptedPrivateKey" + } + }; + + // Act + var result = model.ToUser(existingUser); + + // Assert + Assert.Same(existingUser, result); + Assert.Equal("hint", result.MasterPasswordHint); + Assert.Equal(kdfType, result.Kdf); + Assert.Equal(kdfIterations, result.KdfIterations); + Assert.Equal(kdfMemory, result.KdfMemory); + Assert.Equal(kdfParallelism, result.KdfParallelism); + Assert.Equal("key", result.Key); + Assert.Equal("publicKey", result.PublicKey); + Assert.Equal("encryptedPrivateKey", result.PrivateKey); + } + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [InlineData(KdfType.Argon2id, 3, 64, 4)] + public void ToUser_WithoutKeys_MapsPropertiesCorrectly(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + // Arrange + var existingUser = new User(); + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = "orgIdentifier", + MasterPasswordHash = "hash", + MasterPasswordHint = "hint", + Key = "key", + Kdf = kdfType, + KdfIterations = kdfIterations, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism, + Keys = null + }; + + // Act + var result = model.ToUser(existingUser); + + // Assert + Assert.Same(existingUser, result); + Assert.Equal("hint", result.MasterPasswordHint); + Assert.Equal(kdfType, result.Kdf); + Assert.Equal(kdfIterations, result.KdfIterations); + Assert.Equal(kdfMemory, result.KdfMemory); + Assert.Equal(kdfParallelism, result.KdfParallelism); + Assert.Equal("key", result.Key); + Assert.Null(result.PublicKey); + Assert.Null(result.PrivateKey); + } + + #endregion + + #region ToData Tests + + [Theory] + [BitAutoData] + public void ToData_MapsPropertiesCorrectly(string orgIdentifier) + { + // Arrange + var kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }; + + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHint = "hint", + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + }, + AccountKeys = new AccountKeysRequestModel + { + UserKeyEncryptedAccountPrivateKey = "privateKey", + AccountPublicKey = "publicKey" + } + }; + + // Act + var result = model.ToData(); + + // Assert + Assert.NotNull(result); + Assert.Equal(orgIdentifier, result.OrgSsoIdentifier); + Assert.Equal("hint", result.MasterPasswordHint); + Assert.NotNull(result.MasterPasswordAuthentication); + Assert.NotNull(result.MasterPasswordUnlock); + Assert.NotNull(result.AccountKeys); + Assert.Equal("authHash", result.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); + Assert.Equal("wrappedKey", result.MasterPasswordUnlock.MasterKeyWrappedUserKey); + } + + [Theory] + [BitAutoData] + public void ToData_WithNullAccountKeys_MapsCorrectly(string orgIdentifier) + { + // Arrange + var kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }; + + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHint = "hint", + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + }, + AccountKeys = null + }; + + // Act + var result = model.ToData(); + + // Assert + Assert.NotNull(result); + Assert.Equal(orgIdentifier, result.OrgSsoIdentifier); + Assert.Null(result.AccountKeys); + } + + #endregion +} diff --git a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs index a2aff1b108..763fac7199 100644 --- a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs @@ -1,13 +1,11 @@ using System.Security.Claims; using Bit.Api.Billing.Controllers; using Bit.Core; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.Models.Business; using Bit.Core.Services; using Bit.Core.Settings; @@ -29,8 +27,6 @@ public class AccountsControllerTests : IDisposable private readonly IUserService _userService; private readonly IFeatureService _featureService; private readonly IStripePaymentService _paymentService; - private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; - private readonly IUserAccountKeysQuery _userAccountKeysQuery; private readonly ILicensingService _licensingService; private readonly GlobalSettings _globalSettings; private readonly AccountsController _sut; @@ -40,15 +36,11 @@ public class AccountsControllerTests : IDisposable _userService = Substitute.For(); _featureService = Substitute.For(); _paymentService = Substitute.For(); - _twoFactorIsEnabledQuery = Substitute.For(); - _userAccountKeysQuery = Substitute.For(); _licensingService = Substitute.For(); _globalSettings = new GlobalSettings { SelfHosted = false }; _sut = new AccountsController( _userService, - _twoFactorIsEnabledQuery, - _userAccountKeysQuery, _featureService, _licensingService ); diff --git a/test/Api.Test/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs index 87334dc085..a7eb4dda5e 100644 --- a/test/Api.Test/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs @@ -1,6 +1,9 @@ using Bit.Api.Billing.Controllers; using Bit.Api.Models.Request.Organizations; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Billing.Enums; using Bit.Core.Context; using Bit.Core.Entities; @@ -10,6 +13,7 @@ using Bit.Core.Models.Data; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -82,7 +86,9 @@ public class OrganizationSponsorshipsControllerTests [BitAutoData] public async Task RedeemSponsorship_NotSponsoredOrgOwner_Success(string sponsorshipToken, User user, OrganizationSponsorship sponsorship, Organization sponsoringOrganization, - OrganizationSponsorshipRedeemRequestModel model, SutProvider sutProvider) + OrganizationSponsorshipRedeemRequestModel model, + [Policy(PolicyType.FreeFamiliesSponsorshipPolicy, false)] PolicyStatus policy, + SutProvider sutProvider) { sutProvider.GetDependency().UserId.Returns(user.Id); sutProvider.GetDependency().GetUserByIdAsync(user.Id) @@ -91,6 +97,9 @@ public class OrganizationSponsorshipsControllerTests user.Email).Returns((true, sponsorship)); sutProvider.GetDependency().OrganizationOwner(model.SponsoredOrganizationId).Returns(true); sutProvider.GetDependency().GetByIdAsync(model.SponsoredOrganizationId).Returns(sponsoringOrganization); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), PolicyType.FreeFamiliesSponsorshipPolicy) + .Returns(policy); await sutProvider.Sut.RedeemSponsorship(sponsorshipToken, model); @@ -101,14 +110,18 @@ public class OrganizationSponsorshipsControllerTests [Theory] [BitAutoData] public async Task PreValidateSponsorshipToken_ValidatesToken_Success(string sponsorshipToken, User user, - OrganizationSponsorship sponsorship, SutProvider sutProvider) + OrganizationSponsorship sponsorship, + [Policy(PolicyType.FreeFamiliesSponsorshipPolicy, false)] PolicyStatus policy, + SutProvider sutProvider) { sutProvider.GetDependency().UserId.Returns(user.Id); sutProvider.GetDependency().GetUserByIdAsync(user.Id) .Returns(user); sutProvider.GetDependency() .ValidateRedemptionTokenAsync(sponsorshipToken, user.Email).Returns((true, sponsorship)); - + sutProvider.GetDependency() + .RunAsync(Arg.Any(), PolicyType.FreeFamiliesSponsorshipPolicy) + .Returns(policy); await sutProvider.Sut.PreValidateSponsorshipToken(sponsorshipToken); await sutProvider.GetDependency().Received(1) diff --git a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs index 66d1a4d3e1..5b14608fc0 100644 --- a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs @@ -3,6 +3,8 @@ using Bit.Api.Billing.Models.Requests.Storage; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Licenses.Queries; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Entities; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Http; @@ -17,21 +19,26 @@ public class AccountBillingVNextControllerTests { private readonly IUpdatePremiumStorageCommand _updatePremiumStorageCommand; private readonly IGetUserLicenseQuery _getUserLicenseQuery; + private readonly IUpgradePremiumToOrganizationCommand _upgradePremiumToOrganizationCommand; private readonly AccountBillingVNextController _sut; public AccountBillingVNextControllerTests() { _updatePremiumStorageCommand = Substitute.For(); _getUserLicenseQuery = Substitute.For(); + _upgradePremiumToOrganizationCommand = Substitute.For(); _sut = new AccountBillingVNextController( Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For(), Substitute.For(), _getUserLicenseQuery, + Substitute.For(), Substitute.For(), - _updatePremiumStorageCommand); + _updatePremiumStorageCommand, + _upgradePremiumToOrganizationCommand); } [Theory, BitAutoData] @@ -60,7 +67,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); @@ -80,7 +87,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -100,7 +107,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -120,7 +127,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -140,7 +147,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -160,7 +167,7 @@ public class AccountBillingVNextControllerTests .Returns(new BadRequest(errorMessage)); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var badRequestResult = Assert.IsAssignableFrom(result); @@ -179,7 +186,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); @@ -198,7 +205,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); @@ -217,7 +224,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); @@ -236,7 +243,7 @@ public class AccountBillingVNextControllerTests .Returns(new BillingCommandResult(new None())); // Act - var result = await _sut.UpdateStorageAsync(user, request); + var result = await _sut.UpdateSubscriptionStorageAsync(user, request); // Assert var okResult = Assert.IsAssignableFrom(result); diff --git a/test/Api.Test/Controllers/PoliciesControllerTests.cs b/test/Api.Test/Controllers/PoliciesControllerTests.cs index efb9f7aaa9..03ab20ec28 100644 --- a/test/Api.Test/Controllers/PoliciesControllerTests.cs +++ b/test/Api.Test/Controllers/PoliciesControllerTests.cs @@ -49,7 +49,7 @@ public class PoliciesControllerTests sutProvider.GetDependency() .GetProperUserId(Arg.Any()) - .Returns((Guid?)userId); + .Returns(userId); sutProvider.GetDependency() .GetByOrganizationAsync(orgId, userId) @@ -95,7 +95,7 @@ public class PoliciesControllerTests // Arrange sutProvider.GetDependency() .GetProperUserId(Arg.Any()) - .Returns((Guid?)userId); + .Returns(userId); sutProvider.GetDependency() .GetByOrganizationAsync(orgId, userId) @@ -113,7 +113,7 @@ public class PoliciesControllerTests // Arrange sutProvider.GetDependency() .GetProperUserId(Arg.Any()) - .Returns((Guid?)userId); + .Returns(userId); sutProvider.GetDependency() .GetByOrganizationAsync(orgId, userId) @@ -135,7 +135,7 @@ public class PoliciesControllerTests // Arrange sutProvider.GetDependency() .GetProperUserId(Arg.Any()) - .Returns((Guid?)userId); + .Returns(userId); sutProvider.GetDependency() .GetByOrganizationAsync(orgId, userId) @@ -186,59 +186,35 @@ public class PoliciesControllerTests [Theory] [BitAutoData] public async Task Get_WhenUserCanManagePolicies_WithExistingType_ReturnsExistingPolicy( - SutProvider sutProvider, Guid orgId, Policy policy, int type) + SutProvider sutProvider, Guid orgId, PolicyStatus policy, PolicyType type) { // Arrange sutProvider.GetDependency() .ManagePolicies(orgId) .Returns(true); - policy.Type = (PolicyType)type; + policy.Type = type; policy.Enabled = true; policy.Data = null; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(orgId, (PolicyType)type) + sutProvider.GetDependency() + .RunAsync(orgId, type) .Returns(policy); // Act var result = await sutProvider.Sut.Get(orgId, type); // Assert - Assert.IsType(result); - Assert.Equal(policy.Id, result.Id); + Assert.IsType(result); Assert.Equal(policy.Type, result.Type); Assert.Equal(policy.Enabled, result.Enabled); Assert.Equal(policy.OrganizationId, result.OrganizationId); } - [Theory] - [BitAutoData] - public async Task Get_WhenUserCanManagePolicies_WithNonExistingType_ReturnsDefaultPolicy( - SutProvider sutProvider, Guid orgId, int type) - { - // Arrange - sutProvider.GetDependency() - .ManagePolicies(orgId) - .Returns(true); - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(orgId, (PolicyType)type) - .Returns((Policy)null); - - // Act - var result = await sutProvider.Sut.Get(orgId, type); - - // Assert - Assert.IsType(result); - Assert.Equal(result.Type, (PolicyType)type); - Assert.False(result.Enabled); - } - [Theory] [BitAutoData] public async Task Get_WhenUserCannotManagePolicies_ThrowsNotFoundException( - SutProvider sutProvider, Guid orgId, int type) + SutProvider sutProvider, Guid orgId, PolicyType type) { // Arrange sutProvider.GetDependency() diff --git a/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs b/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs index 964c801903..a939636fc2 100644 --- a/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs +++ b/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs @@ -69,6 +69,44 @@ public class OrganizationUserRotationValidatorTests Assert.Empty(result); } + [Theory] + [BitAutoData([null])] + [BitAutoData("")] + public async Task ValidateAsync_OrgUsersWithNullOrEmptyResetPasswordKey_FiltersOutInvalidKeys( + string? invalidResetPasswordKey, + SutProvider sutProvider, User user, + ResetPasswordWithOrgIdRequestModel validResetPasswordKey) + { + // Arrange + var existingUserResetPassword = new List + { + // Valid org user with reset password key + new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = validResetPasswordKey.OrganizationId, + ResetPasswordKey = validResetPasswordKey.ResetPasswordKey + }, + // Invalid org user with null or empty reset password key - should be filtered out + new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + ResetPasswordKey = invalidResetPasswordKey + } + }; + sutProvider.GetDependency().GetManyByUserAsync(user.Id) + .Returns(existingUserResetPassword); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, new[] { validResetPasswordKey }); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal(validResetPasswordKey.OrganizationId, result[0].OrganizationId); + } + [Theory] [BitAutoData] public async Task ValidateAsync_MissingResetPassword_Throws( diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs index 541e8a4903..9322948037 100644 --- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs @@ -8,8 +8,8 @@ using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; using Bit.Core.Services; -using Bit.Core.Settings; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Data; @@ -28,7 +28,6 @@ namespace Bit.Api.Test.Tools.Controllers; public class SendsControllerTests : IDisposable { private readonly SendsController _sut; - private readonly GlobalSettings _globalSettings; private readonly IUserService _userService; private readonly ISendRepository _sendRepository; private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; @@ -37,6 +36,8 @@ public class SendsControllerTests : IDisposable private readonly ISendAuthorizationService _sendAuthorizationService; private readonly ISendFileStorageService _sendFileStorageService; private readonly ILogger _logger; + private readonly IFeatureService _featureService; + private readonly IPushNotificationService _pushNotificationService; public SendsControllerTests() { @@ -47,8 +48,9 @@ public class SendsControllerTests : IDisposable _sendOwnerQuery = Substitute.For(); _sendAuthorizationService = Substitute.For(); _sendFileStorageService = Substitute.For(); - _globalSettings = new GlobalSettings(); _logger = Substitute.For>(); + _featureService = Substitute.For(); + _pushNotificationService = Substitute.For(); _sut = new SendsController( _sendRepository, @@ -59,7 +61,8 @@ public class SendsControllerTests : IDisposable _sendOwnerQuery, _sendFileStorageService, _logger, - _globalSettings + _featureService, + _pushNotificationService ); } @@ -96,8 +99,8 @@ public class SendsControllerTests : IDisposable { var now = DateTime.UtcNow; var expected = "You cannot have a Send with a deletion date that far " + - "into the future. Adjust the Deletion Date to a value less than 31 days from now " + - "and try again."; + "into the future. Adjust the Deletion Date to a value less than 31 days from now " + + "and try again."; var request = new SendRequestModel() { DeletionDate = now.AddDays(32) }; var exception = await Assert.ThrowsAsync(() => _sut.Post(request)); @@ -109,9 +112,10 @@ public class SendsControllerTests : IDisposable { var now = DateTime.UtcNow; var expected = "You cannot have a Send with a deletion date that far " + - "into the future. Adjust the Deletion Date to a value less than 31 days from now " + - "and try again."; - var request = new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) }; + "into the future. Adjust the Deletion Date to a value less than 31 days from now " + + "and try again."; + var request = + new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) }; var exception = await Assert.ThrowsAsync(() => _sut.PostFile(request)); Assert.Equal(expected, exception.Message); @@ -409,7 +413,8 @@ public class SendsControllerTests : IDisposable } [Theory, AutoData] - public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId) + public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, + Guid sendId) { _userService.GetProperUserId(Arg.Any()).Returns(userId); var existingSend = new Send @@ -753,4 +758,484 @@ public class SendsControllerTests : IDisposable s.Password == null && s.Emails == null)); } + + #region Authenticated Access Endpoints + + [Theory, AutoData] + public async Task AccessUsingAuth_WithValidSend_ReturnsSendAccessResponse(Guid sendId, User creator) + { + var send = new Send + { + Id = sendId, + UserId = creator.Id, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + HideEmail = false, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + _userService.GetUserByIdAsync(creator.Id).Returns(creator); + + var result = await _sut.AccessUsingAuth(); + + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + var response = Assert.IsType(objectResult.Value); + Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id); + Assert.Equal(creator.Email, response.CreatorIdentifier); + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _userService.Received(1).GetUserByIdAsync(creator.Id); + } + + [Theory, AutoData] + public async Task AccessUsingAuth_WithHideEmail_DoesNotIncludeCreatorIdentifier(Guid sendId, User creator) + { + var send = new Send + { + Id = sendId, + UserId = creator.Id, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + HideEmail = true, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + var result = await _sut.AccessUsingAuth(); + + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + var response = Assert.IsType(objectResult.Value); + Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id); + Assert.Null(response.CreatorIdentifier); + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task AccessUsingAuth_WithNoUserId_DoesNotIncludeCreatorIdentifier(Guid sendId) + { + var send = new Send + { + Id = sendId, + UserId = null, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + HideEmail = false, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + var result = await _sut.AccessUsingAuth(); + + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + var response = Assert.IsType(objectResult.Value); + Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id); + Assert.Null(response.CreatorIdentifier); + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task AccessUsingAuth_WithNonExistentSend_ThrowsBadRequestException(Guid sendId) + { + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns((Send)null); + + var exception = + await Assert.ThrowsAsync(() => _sut.AccessUsingAuth()); + + Assert.Equal("Could not locate send", exception.Message); + await _sendRepository.Received(1).GetByIdAsync(sendId); + } + + [Theory, AutoData] + public async Task AccessUsingAuth_WithFileSend_ReturnsCorrectResponse(Guid sendId, User creator) + { + var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 }; + var send = new Send + { + Id = sendId, + UserId = creator.Id, + Type = SendType.File, + Data = JsonSerializer.Serialize(fileData), + HideEmail = false, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + _userService.GetUserByIdAsync(creator.Id).Returns(creator); + + var result = await _sut.AccessUsingAuth(); + + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + var response = Assert.IsType(objectResult.Value); + Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id); + Assert.Equal(SendType.File, response.Type); + Assert.NotNull(response.File); + Assert.Equal("file-123", response.File.Id); + Assert.Equal(creator.Email, response.CreatorIdentifier); + } + + [Theory, AutoData] + public async Task GetSendFileDownloadDataUsingAuth_WithValidFileId_ReturnsDownloadUrl( + Guid sendId, string fileId, string expectedUrl) + { + var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = fileId, Size = 2048 }; + var send = new Send + { + Id = sendId, + Type = SendType.File, + Data = JsonSerializer.Serialize(fileData), + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl); + + var result = await _sut.GetSendFileDownloadDataUsingAuth(fileId); + + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + var response = Assert.IsType(objectResult.Value); + Assert.Equal(fileId, response.Id); + Assert.Equal(expectedUrl, response.Url); + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _sendFileStorageService.Received(1).GetSendFileDownloadUrlAsync(send, fileId); + } + + [Theory, AutoData] + public async Task GetSendFileDownloadDataUsingAuth_WithNonExistentSend_ThrowsBadRequestException( + Guid sendId, string fileId) + { + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns((Send)null); + + var exception = + await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId)); + + Assert.Equal("Could not locate send", exception.Message); + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _sendFileStorageService.DidNotReceive() + .GetSendFileDownloadUrlAsync(Arg.Any(), Arg.Any()); + } + + [Theory, AutoData] + public async Task GetSendFileDownloadDataUsingAuth_WithTextSend_StillReturnsResponse( + Guid sendId, string fileId, string expectedUrl) + { + var send = new Send + { + Id = sendId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null, + Disabled = false, + AccessCount = 0, + MaxAccessCount = null + }; + var user = CreateUserWithSendIdClaim(sendId); + _sut.ControllerContext = CreateControllerContextWithUser(user); + _sendRepository.GetByIdAsync(sendId).Returns(send); + _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl); + + var result = await _sut.GetSendFileDownloadDataUsingAuth(fileId); + + Assert.NotNull(result); + var objectResult = Assert.IsType(result); + var response = Assert.IsType(objectResult.Value); + Assert.Equal(fileId, response.Id); + Assert.Equal(expectedUrl, response.Url); + } + + + #endregion + + #region PutRemoveAuth Tests + + [Theory, AutoData] + public async Task PutRemoveAuth_WithPasswordProtectedSend_RemovesPasswordAndSetsAuthTypeNone(Guid userId, + Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = "hashed-password", + Emails = null, + AuthType = AuthType.Password + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemoveAuth(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + Assert.Null(result.Password); + Assert.Null(result.Emails); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.Password == null && + s.Emails == null && + s.AuthType == AuthType.None)); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithEmailProtectedSend_RemovesEmailsAndSetsAuthTypeNone(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = null, + Emails = "test@example.com,user@example.com", + AuthType = AuthType.Email + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemoveAuth(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + Assert.Null(result.Password); + Assert.Null(result.Emails); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.Password == null && + s.Emails == null && + s.AuthType == AuthType.None)); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithSendAlreadyHavingNoAuth_StillSucceeds(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = null, + Emails = null, + AuthType = AuthType.None + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemoveAuth(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + Assert.Null(result.Password); + Assert.Null(result.Emails); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.Password == null && + s.Emails == null && + s.AuthType == AuthType.None)); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithFileSend_RemovesAuthAndPreservesFileData(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 }; + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.File, + Data = JsonSerializer.Serialize(fileData), + Password = "hashed-password", + Emails = null, + AuthType = AuthType.Password + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemoveAuth(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + Assert.Equal(SendType.File, result.Type); + Assert.NotNull(result.File); + Assert.Equal("file-123", result.File.Id); + Assert.Null(result.Password); + Assert.Null(result.Emails); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + _sendRepository.GetByIdAsync(sendId).Returns((Send)null); + + await Assert.ThrowsAsync(() => _sut.PutRemoveAuth(sendId.ToString())); + + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = otherUserId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = "hashed-password", + AuthType = AuthType.Password + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + await Assert.ThrowsAsync(() => _sut.PutRemoveAuth(sendId.ToString())); + + await _sendRepository.Received(1).GetByIdAsync(sendId); + await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithNullUserId_ThrowsInvalidOperationException(Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns((Guid?)null); + + var exception = + await Assert.ThrowsAsync(() => _sut.PutRemoveAuth(sendId.ToString())); + + Assert.Equal("User ID not found", exception.Message); + await _sendRepository.DidNotReceive().GetByIdAsync(Arg.Any()); + await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_WithSendHavingBothPasswordAndEmails_RemovesBoth(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = "hashed-password", + Emails = "test@example.com", + AuthType = AuthType.Password + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemoveAuth(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + Assert.Null(result.Password); + Assert.Null(result.Emails); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.Password == null && + s.Emails == null && + s.AuthType == AuthType.None)); + } + + [Theory, AutoData] + public async Task PutRemoveAuth_PreservesOtherSendProperties(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var deletionDate = DateTime.UtcNow.AddDays(7); + var expirationDate = DateTime.UtcNow.AddDays(3); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = "hashed-password", + AuthType = AuthType.Password, + Key = "encryption-key", + MaxAccessCount = 10, + AccessCount = 3, + DeletionDate = deletionDate, + ExpirationDate = expirationDate, + Disabled = false, + HideEmail = true + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemoveAuth(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + // Verify other properties are preserved + Assert.Equal("encryption-key", result.Key); + Assert.Equal(10, result.MaxAccessCount); + Assert.Equal(3, result.AccessCount); + Assert.Equal(deletionDate, result.DeletionDate); + Assert.Equal(expirationDate, result.ExpirationDate); + Assert.False(result.Disabled); + Assert.True(result.HideEmail); + } + + #endregion + + #region Test Helpers + + private static ClaimsPrincipal CreateUserWithSendIdClaim(Guid sendId) + { + var claims = new List { new Claim("send_id", sendId.ToString()) }; + var identity = new ClaimsIdentity(claims, "TestAuth"); + return new ClaimsPrincipal(identity); + } + + private static ControllerContext CreateControllerContextWithUser(ClaimsPrincipal user) + { + return new ControllerContext { HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext { User = user } }; + } + + #endregion } diff --git a/test/Billing.Test/Controllers/PayPalControllerTests.cs b/test/Billing.Test/Controllers/PayPalControllerTests.cs index da995b6188..6a9ecc9fd2 100644 --- a/test/Billing.Test/Controllers/PayPalControllerTests.cs +++ b/test/Billing.Test/Controllers/PayPalControllerTests.cs @@ -1,4 +1,5 @@ -using System.Text; +using System.Globalization; +using System.Text; using Bit.Billing.Controllers; using Bit.Billing.Test.Utilities; using Bit.Core.AdminConsole.Entities; @@ -565,4 +566,53 @@ public class PayPalControllerTests(ITestOutputHelper testOutputHelper) private static void LoggedWarning(ICacheLogger logger, string message) => Logged(logger, LogLevel.Warning, message); + + [Fact] + public async Task PostIpn_Completed_CreatesTransaction_WithSwedishCulture_Ok() + { + // Save current culture + var originalCulture = CultureInfo.CurrentCulture; + var originalUICulture = CultureInfo.CurrentUICulture; + + try + { + // Set Swedish culture (uses comma as decimal separator) + var swedishCulture = new CultureInfo("sv-SE"); + CultureInfo.CurrentCulture = swedishCulture; + CultureInfo.CurrentUICulture = swedishCulture; + + var logger = testOutputHelper.BuildLoggerFor(); + + _billingSettings.Value.Returns(new BillingSettings + { + PayPal = + { + WebhookKey = _defaultWebhookKey, + BusinessId = "NHDYKLQ3L4LWL" + } + }); + + var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment); + + _transactionRepository.GetByGatewayIdAsync( + GatewayType.PayPal, + "2PK15573S8089712Y").ReturnsNull(); + + var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); + + var result = await controller.PostIpn(); + + HasStatusCode(result, 200); + + await _transactionRepository.Received().CreateAsync(Arg.Is(transaction => + transaction.Amount == 48M && + transaction.GatewayId == "2PK15573S8089712Y")); + } + finally + { + // Restore original culture + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUICulture; + } + } } diff --git a/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs index b3540246b0..34ac030453 100644 --- a/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs +++ b/test/Billing.Test/Jobs/ReconcileAdditionalStorageJobTests.cs @@ -2,6 +2,7 @@ using Bit.Billing.Services; using Bit.Core; using Bit.Core.Billing.Constants; +using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; using NSubstitute; @@ -17,6 +18,9 @@ public class ReconcileAdditionalStorageJobTests private readonly IStripeFacade _stripeFacade; private readonly ILogger _logger; private readonly IFeatureService _featureService; + private readonly IUserRepository _userRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly ReconcileAdditionalStorageJob _sut; public ReconcileAdditionalStorageJobTests() @@ -24,7 +28,20 @@ public class ReconcileAdditionalStorageJobTests _stripeFacade = Substitute.For(); _logger = Substitute.For>(); _featureService = Substitute.For(); - _sut = new ReconcileAdditionalStorageJob(_stripeFacade, _logger, _featureService); + _userRepository = Substitute.For(); + _organizationRepository = Substitute.For(); + _stripeEventUtilityService = Substitute.For(); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, null, null)); + + _sut = new ReconcileAdditionalStorageJob( + _stripeFacade, + _logger, + _featureService, + _userRepository, + _organizationRepository, + _stripeEventUtilityService); } #region Feature Flag Tests @@ -88,6 +105,36 @@ public class ReconcileAdditionalStorageJobTests await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!); } + [Fact] + public async Task Execute_DryRunMode_DoesNotUpdateDatabase() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(false); // Dry run ON + + // Create a personal subscription that would normally trigger a database update + var userId = Guid.NewGuid(); + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + subscription.Metadata = new Dictionary { ["userId"] = userId.ToString() }; + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + + // Mock GetIdsFromMetadata to return userId + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, userId, null)); + + // Act + await _sut.Execute(context); + + // Assert - Verify database repositories are never called + await _userRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default); + await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default); + await _organizationRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + [Fact] public async Task Execute_DryRunModeDisabled_UpdatesSubscriptions() { @@ -96,7 +143,11 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); // Dry run OFF + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) @@ -111,6 +162,150 @@ public class ReconcileAdditionalStorageJobTests Arg.Is(o => o.Items.Count == 1)); } + [Fact] + public async Task Execute_LiveMode_PersonalSubscription_UpdatesUserDatabase() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + // Setup user + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User + { + Id = userId, + Email = "test@example.com", + GatewaySubscriptionId = "sub_personal", + MaxStorageGb = 15 // Old value + }; + _userRepository.GetByIdAsync(userId).Returns(user); + _userRepository.ReplaceAsync(user).Returns(Task.CompletedTask); + + // Create personal subscription with premium seat + 10 GB storage (will be reduced to 6 GB) + var subscription = CreateSubscriptionWithMultipleItems("sub_personal", + [("premium-annually", 1L), ("storage-gb-monthly", 10L)]); + subscription.Metadata = new Dictionary { ["userId"] = userId.ToString() }; + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Mock GetIdsFromMetadata to return userId + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, userId, null)); + + // Act + await _sut.Execute(context); + + // Assert - Verify Stripe update happened + await _stripeFacade.Received(1).UpdateSubscription( + "sub_personal", + Arg.Is(o => o.Items.Count == 1 && o.Items[0].Quantity == 6)); + + // Assert - Verify database update with correct MaxStorageGb (5 included + 6 new quantity = 11) + await _userRepository.Received(1).GetByIdAsync(userId); + await _userRepository.Received(1).ReplaceAsync(user); + Assert.Equal((short)11, user.MaxStorageGb); + } + + [Fact] + public async Task Execute_LiveMode_OrganizationSubscription_UpdatesOrganizationDatabase() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + // Setup organization + var organizationId = Guid.NewGuid(); + var organization = new Bit.Core.AdminConsole.Entities.Organization + { + Id = organizationId, + Name = "Test Organization", + GatewaySubscriptionId = "sub_org", + MaxStorageGb = 13 // Old value + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + _organizationRepository.ReplaceAsync(organization).Returns(Task.CompletedTask); + + // Create organization subscription with org seat plan + 8 GB storage (will be reduced to 4 GB) + var subscription = CreateSubscriptionWithMultipleItems("sub_org", + [("2023-teams-org-seat-annually", 5L), ("storage-gb-monthly", 8L)]); + subscription.Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() }; + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Mock GetIdsFromMetadata to return organizationId + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(organizationId, null, null)); + + // Act + await _sut.Execute(context); + + // Assert - Verify Stripe update happened + await _stripeFacade.Received(1).UpdateSubscription( + "sub_org", + Arg.Is(o => o.Items.Count == 1 && o.Items[0].Quantity == 4)); + + // Assert - Verify database update with correct MaxStorageGb (5 included + 4 new quantity = 9) + await _organizationRepository.Received(1).GetByIdAsync(organizationId); + await _organizationRepository.Received(1).ReplaceAsync(organization); + Assert.Equal((short)9, organization.MaxStorageGb); + } + + [Fact] + public async Task Execute_LiveMode_StorageItemDeleted_UpdatesDatabaseWithBaseStorage() + { + // Arrange + var context = CreateJobExecutionContext(); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); + _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + + // Setup user + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User + { + Id = userId, + Email = "test@example.com", + GatewaySubscriptionId = "sub_delete", + MaxStorageGb = 8 // Old value + }; + _userRepository.GetByIdAsync(userId).Returns(user); + _userRepository.ReplaceAsync(user).Returns(Task.CompletedTask); + + // Create personal subscription with premium seat + 3 GB storage (will be deleted since 3 < 4) + var subscription = CreateSubscriptionWithMultipleItems("sub_delete", + [("premium-annually", 1L), ("storage-gb-monthly", 3L)]); + subscription.Metadata = new Dictionary { ["userId"] = userId.ToString() }; + + _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) + .Returns(AsyncEnumerable.Create(subscription)); + _stripeFacade.UpdateSubscription(Arg.Any(), Arg.Any()) + .Returns(subscription); + + // Mock GetIdsFromMetadata to return userId + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, userId, null)); + + // Act + await _sut.Execute(context); + + // Assert - Verify Stripe update happened (item deleted) + await _stripeFacade.Received(1).UpdateSubscription( + "sub_delete", + Arg.Is(o => o.Items.Count == 1 && o.Items[0].Deleted == true)); + + // Assert - Verify database update with base storage only (5 GB) + await _userRepository.Received(1).GetByIdAsync(userId); + await _userRepository.Received(1).ReplaceAsync(user); + Assert.Equal((short)5, user.MaxStorageGb); + } + #endregion #region Price ID Processing Tests @@ -174,11 +369,14 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var metadata = new Dictionary { [StripeConstants.MetadataKeys.StorageReconciled2025] = "invalid-date" }; var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -200,7 +398,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: null); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -226,7 +427,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -253,7 +457,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 4); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -279,7 +486,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 2); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -309,7 +519,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -333,7 +546,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -429,9 +645,12 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10); var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5); var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3)); @@ -461,6 +680,7 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var processedMetadata = new Dictionary { [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") @@ -469,6 +689,8 @@ public class ReconcileAdditionalStorageJobTests var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10); var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5, metadata: processedMetadata); var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3)); @@ -501,9 +723,12 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10); var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5); var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3)); @@ -563,7 +788,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Active); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -585,7 +813,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Trialing); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -607,7 +838,10 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.PastDue); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(subscription)); @@ -669,11 +903,14 @@ public class ReconcileAdditionalStorageJobTests _featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true); _featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); + var userId = Guid.NewGuid(); var activeSubscription = CreateSubscription("sub_active", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Active); var trialingSubscription = CreateSubscription("sub_trialing", "storage-gb-monthly", quantity: 8, status: StripeConstants.SubscriptionStatus.Trialing); var pastDueSubscription = CreateSubscription("sub_pastdue", "storage-gb-monthly", quantity: 6, status: StripeConstants.SubscriptionStatus.PastDue); var canceledSubscription = CreateSubscription("sub_canceled", "storage-gb-monthly", quantity: 5, status: StripeConstants.SubscriptionStatus.Canceled); var incompleteSubscription = CreateSubscription("sub_incomplete", "storage-gb-monthly", quantity: 4, status: StripeConstants.SubscriptionStatus.Incomplete); + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(null, userId, null)); _stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any()) .Returns(AsyncEnumerable.Create(activeSubscription, trialingSubscription, pastDueSubscription, canceledSubscription, incompleteSubscription)); @@ -731,6 +968,410 @@ public class ReconcileAdditionalStorageJobTests #endregion + #region Helper Method Tests + + #region DetermineSubscriptionPlanTier Tests + + [Fact] + public void DetermineSubscriptionPlanTier_WithUserId_ReturnsPersonal() + { + // Arrange + var userId = Guid.NewGuid(); + Guid? organizationId = null; + + // Act + var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId); + + // Assert + Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, result); + } + + [Fact] + public void DetermineSubscriptionPlanTier_WithOrganizationId_ReturnsOrganization() + { + // Arrange + Guid? userId = null; + var organizationId = Guid.NewGuid(); + + // Act + var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId); + + // Assert + Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization, result); + } + + [Fact] + public void DetermineSubscriptionPlanTier_WithBothIds_ReturnsPersonal() + { + // Arrange - Personal takes precedence + var userId = Guid.NewGuid(); + var organizationId = Guid.NewGuid(); + + // Act + var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId); + + // Assert + Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, result); + } + + [Fact] + public void DetermineSubscriptionPlanTier_WithNoIds_ReturnsUnknown() + { + // Arrange + Guid? userId = null; + Guid? organizationId = null; + + // Act + var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId); + + // Assert + Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Unknown, result); + } + + #endregion + + #region GetCurrentStorageQuantityFromSubscription Tests + + [Theory] + [InlineData("storage-gb-monthly", 10L, 10L)] + [InlineData("storage-gb-annually", 25L, 25L)] + [InlineData("personal-storage-gb-annually", 5L, 5L)] + [InlineData("storage-gb-monthly", 0L, 0L)] + public void GetCurrentStorageQuantityFromSubscription_WithMatchingPriceId_ReturnsQuantity( + string priceId, long quantity, long expectedQuantity) + { + // Arrange + var subscription = CreateSubscription("sub_123", priceId, quantity); + + // Act + var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, priceId); + + // Assert + Assert.Equal(expectedQuantity, result); + } + + [Fact] + public void GetCurrentStorageQuantityFromSubscription_WithNonMatchingPriceId_ReturnsZero() + { + // Arrange + var subscription = CreateSubscription("sub_123", "storage-gb-monthly", 10L); + + // Act + var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, "different-price-id"); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetCurrentStorageQuantityFromSubscription_WithNullItems_ReturnsZero() + { + // Arrange + var subscription = new Subscription { Id = "sub_123", Items = null }; + + // Act + var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, "storage-gb-monthly"); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetCurrentStorageQuantityFromSubscription_WithEmptyItems_ReturnsZero() + { + // Arrange + var subscription = new Subscription + { + Id = "sub_123", + Items = new StripeList { Data = [] } + }; + + // Act + var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, "storage-gb-monthly"); + + // Assert + Assert.Equal(0, result); + } + + #endregion + + #region CalculateNewMaxStorageGb Tests + + [Theory] + [InlineData(10L, 6L, 11)] // 5 included + 6 new quantity + [InlineData(15L, 11L, 16)] // 5 included + 11 new quantity + [InlineData(4L, 0L, 5)] // Item deleted, returns base storage + [InlineData(2L, 0L, 5)] // Item deleted, returns base storage + [InlineData(8L, 4L, 9)] // 5 included + 4 new quantity + public void CalculateNewMaxStorageGb_WithQuantityUpdate_ReturnsCorrectMaxStorage( + long currentQuantity, long newQuantity, short expectedMaxStorageGb) + { + // Arrange + var updateOptions = new SubscriptionUpdateOptions + { + Items = + [ + newQuantity == 0 + ? new SubscriptionItemOptions { Id = "si_123", Deleted = true } // Item marked as deleted + : new SubscriptionItemOptions { Id = "si_123", Quantity = newQuantity } // Item quantity updated + ] + }; + + // Act + var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions); + + // Assert + Assert.Equal(expectedMaxStorageGb, result); + } + + [Fact] + public void CalculateNewMaxStorageGb_WithNullUpdateOptions_ReturnsCurrentQuantityPlusBaseIncluded() + { + // Arrange + const long currentQuantity = 10; + + // Act + var result = _sut.CalculateNewMaxStorageGb(currentQuantity, null); + + // Assert + Assert.Equal((short)(5 + currentQuantity), result); + } + + [Fact] + public void CalculateNewMaxStorageGb_WithNullItems_ReturnsCurrentQuantityPlusBaseIncluded() + { + // Arrange + const long currentQuantity = 10; + var updateOptions = new SubscriptionUpdateOptions { Items = null }; + + // Act + var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions); + + // Assert + Assert.Equal(5 + currentQuantity, result); + } + + [Fact] + public void CalculateNewMaxStorageGb_WithEmptyItems_ReturnsCurrentQuantity() + { + // Arrange + const long currentQuantity = 10; + var updateOptions = new SubscriptionUpdateOptions + { + Items = [] + }; + + // Act + var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions); + + // Assert + Assert.Equal(5 + currentQuantity, result); + } + + [Fact] + public void CalculateNewMaxStorageGb_WithDeletedItem_ReturnsBaseStorage() + { + // Arrange + const long currentQuantity = 100; + var updateOptions = new SubscriptionUpdateOptions + { + Items = [new SubscriptionItemOptions { Id = "si_123", Deleted = true }] + }; + + // Act + var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions); + + // Assert + Assert.Equal((short)5, result); // Base storage + } + + [Fact] + public void CalculateNewMaxStorageGb_WithItemWithoutQuantity_ReturnsCurrentQuantity() + { + // Arrange + const long currentQuantity = 10; + var updateOptions = new SubscriptionUpdateOptions + { + Items = [new SubscriptionItemOptions { Id = "si_123", Quantity = null }] + }; + + // Act + var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions); + + // Assert + Assert.Equal(5 + currentQuantity, result); + } + + #endregion + + #region UpdateDatabaseMaxStorageAsync Tests + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_PersonalTier_UpdatesUser() + { + // Arrange + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User + { + Id = userId, + Email = "test@example.com", + GatewaySubscriptionId = "sub_123" + }; + _userRepository.GetByIdAsync(userId).Returns(user); + _userRepository.ReplaceAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, + userId, + 10, + "sub_123"); + + // Assert + Assert.True(result); + Assert.Equal((short)10, user.MaxStorageGb); + await _userRepository.Received(1).GetByIdAsync(userId); + await _userRepository.Received(1).ReplaceAsync(user); + } + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_PersonalTier_UserNotFound_ReturnsFalse() + { + // Arrange + var userId = Guid.NewGuid(); + _userRepository.GetByIdAsync(userId).Returns((Bit.Core.Entities.User?)null); + + // Act + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, + userId, + 10, + "sub_123"); + + // Assert + Assert.False(result); + await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_PersonalTier_ReplaceThrowsException_ReturnsFalse() + { + // Arrange + var userId = Guid.NewGuid(); + var user = new Bit.Core.Entities.User + { + Id = userId, + Email = "test@example.com", + GatewaySubscriptionId = "sub_123" + }; + _userRepository.GetByIdAsync(userId).Returns(user); + _userRepository.ReplaceAsync(user).Throws(new Exception("Database error")); + + // Act + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, + userId, + 10, + "sub_123"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_OrganizationTier_UpdatesOrganization() + { + // Arrange + var organizationId = Guid.NewGuid(); + var organization = new Bit.Core.AdminConsole.Entities.Organization + { + Id = organizationId, + Name = "Test Org", + GatewaySubscriptionId = "sub_456" + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + _organizationRepository.ReplaceAsync(organization).Returns(Task.CompletedTask); + + // Act + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization, + organizationId, + 20, + "sub_456"); + + // Assert + Assert.True(result); + Assert.Equal((short)20, organization.MaxStorageGb); + await _organizationRepository.Received(1).GetByIdAsync(organizationId); + await _organizationRepository.Received(1).ReplaceAsync(organization); + } + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_OrganizationTier_OrganizationNotFound_ReturnsFalse() + { + // Arrange + var organizationId = Guid.NewGuid(); + _organizationRepository.GetByIdAsync(organizationId) + .Returns((Bit.Core.AdminConsole.Entities.Organization?)null); + + // Act + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization, + organizationId, + 20, + "sub_456"); + + // Assert + Assert.False(result); + await _organizationRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + } + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_OrganizationTier_ReplaceThrowsException_ReturnsFalse() + { + // Arrange + var organizationId = Guid.NewGuid(); + var organization = new Bit.Core.AdminConsole.Entities.Organization + { + Id = organizationId, + Name = "Test Org", + GatewaySubscriptionId = "sub_456" + }; + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + _organizationRepository.ReplaceAsync(organization).Throws(new Exception("Database error")); + + // Act + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization, + organizationId, + 20, + "sub_456"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task UpdateDatabaseMaxStorageAsync_UnknownTier_ReturnsFalse() + { + // Arrange & Act + var entityId = Guid.NewGuid(); + var result = await _sut.UpdateDatabaseMaxStorageAsync( + ReconcileAdditionalStorageJob.SubscriptionPlanTier.Unknown, + entityId, + 15, + "sub_789"); + + // Assert + Assert.False(result); + await _userRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default); + await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default); + } + + #endregion + + #endregion + #region Helper Methods private static IJobExecutionContext CreateJobExecutionContext(CancellationToken cancellationToken = default) @@ -762,7 +1403,27 @@ public class ReconcileAdditionalStorageJobTests Metadata = metadata, Items = new StripeList { - Data = new List { item } + Data = [item] + } + }; + } + + private static Subscription CreateSubscriptionWithMultipleItems(string id, (string priceId, long quantity)[] items) + { + var subscriptionItems = items.Select(i => new SubscriptionItem + { + Id = $"si_{id}_{i.priceId}", + Price = new Price { Id = i.priceId }, + Quantity = i.quantity + }).ToList(); + + return new Subscription + { + Id = id, + Status = StripeConstants.SubscriptionStatus.Active, + Items = new StripeList + { + Data = subscriptionItems } }; } diff --git a/test/Billing.Test/Services/StripeEventServiceTests.cs b/test/Billing.Test/Services/StripeEventServiceTests.cs index 68aeab2f44..c438ef663c 100644 --- a/test/Billing.Test/Services/StripeEventServiceTests.cs +++ b/test/Billing.Test/Services/StripeEventServiceTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Repositories; using Bit.Core.Settings; +using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; @@ -28,7 +29,13 @@ public class StripeEventServiceTests _providerRepository = Substitute.For(); _setupIntentCache = Substitute.For(); _stripeFacade = Substitute.For(); - _stripeEventService = new StripeEventService(globalSettings, _organizationRepository, _providerRepository, _setupIntentCache, _stripeFacade); + _stripeEventService = new StripeEventService( + globalSettings, + Substitute.For>(), + _organizationRepository, + _providerRepository, + _setupIntentCache, + _stripeFacade); } #region GetCharge diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 182f09e163..2259d846b7 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.Billing.Mocks; using Bit.Core.Test.Billing.Mocks.Plans; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; @@ -654,6 +655,8 @@ public class SubscriptionUpdatedHandlerTests var plan = new Enterprise2023Plan(true); _pricingClient.GetPlanOrThrow(organization.PlanType) .Returns(plan); + _pricingClient.ListPlans() + .Returns(MockPlans.Plans); var parsedEvent = new Event { @@ -693,6 +696,92 @@ public class SubscriptionUpdatedHandlerTests await _stripeFacade.Received(1).DeleteCustomerDiscount(subscription.CustomerId); await _stripeFacade.Received(1).DeleteSubscriptionDiscount(subscription.Id); } + [Fact] + public async Task + HandleAsync_WhenUpgradingPlan_AndPreviousPlanHasSecretsManagerTrial_AndCurrentPlanHasSecretsManagerTrial_DoesNotRemovePasswordManagerCoupon() + { + // Arrange + var organizationId = Guid.NewGuid(); + var subscription = new Subscription + { + Id = "sub_123", + Status = StripeSubscriptionStatus.Active, + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + }, + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), + Plan = new Plan { Id = "secrets-manager-enterprise-seat-annually" } + } + ] + }, + Customer = new Customer + { + Balance = 0, + Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } } + }, + Discounts = [new Discount { Coupon = new Coupon { Id = "sm-standalone" } }], + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } + }; + + // Note: The organization plan is still the previous plan because the subscription is updated before the organization is updated + var organization = new Organization { Id = organizationId, PlanType = PlanType.TeamsAnnually2023 }; + + var plan = new Teams2023Plan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType) + .Returns(plan); + _pricingClient.ListPlans() + .Returns(MockPlans.Plans); + + var parsedEvent = new Event + { + Data = new EventData + { + Object = subscription, + PreviousAttributes = JObject.FromObject(new + { + items = new + { + data = new[] + { + new { plan = new { id = "secrets-manager-teams-seat-annually" } }, + } + }, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-teams-seat-annually" } }, + ] + } + }) + } + }; + + _stripeEventService.GetSubscription(Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(subscription); + + _stripeEventUtilityService.GetIdsFromMetadata(Arg.Any>()) + .Returns(Tuple.Create(organizationId, null, null)); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.DidNotReceive().DeleteCustomerDiscount(subscription.CustomerId); + await _stripeFacade.DidNotReceive().DeleteSubscriptionDiscount(subscription.Id); + } [Theory] [MemberData(nameof(GetNonActiveSubscriptions))] diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 3b133c7d37..82d6c8acfd 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -280,7 +280,7 @@ public class UpcomingInvoiceHandlerTests email.ToEmails.Contains("user@example.com") && email.Subject == "Your Bitwarden Premium renewal is updating" && email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) && - email.View.DiscountedMonthlyRenewalPrice == (discountedPrice / 12).ToString("C", new CultureInfo("en-US")) && + email.View.DiscountedAnnualRenewalPrice == discountedPrice.ToString("C", new CultureInfo("en-US")) && email.View.DiscountAmount == $"{coupon.PercentOff}%" )); } @@ -2436,7 +2436,7 @@ public class UpcomingInvoiceHandlerTests email.Subject == "Your Bitwarden Premium renewal is updating" && email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) && email.View.DiscountAmount == "30%" && - email.View.DiscountedMonthlyRenewalPrice == (expectedDiscountedPrice / 12).ToString("C", new CultureInfo("en-US")) + email.View.DiscountedAnnualRenewalPrice == expectedDiscountedPrice.ToString("C", new CultureInfo("en-US")) )); await _mailService.DidNotReceive().SendInvoiceUpcoming( diff --git a/test/Common/AutoFixture/SutProvider.cs b/test/Common/AutoFixture/SutProvider.cs index e1b37a9827..295f6bc950 100644 --- a/test/Common/AutoFixture/SutProvider.cs +++ b/test/Common/AutoFixture/SutProvider.cs @@ -26,6 +26,7 @@ public class SutProvider : ISutProvider public TSut Sut { get; private set; } public Type SutType => typeof(TSut); + public IFixture Fixture => _fixture; public SutProvider() : this(new Fixture()) { } @@ -65,6 +66,19 @@ public class SutProvider : ISutProvider return this; } + /// + /// Creates and registers a dependency to be injected when the sut is created. + /// + /// The Dependency type to create + /// The (optional) parameter name to register the dependency under + /// The created dependency value + public TDep CreateDependency(string parameterName = "") + { + var dependency = _fixture.Create(); + SetDependency(dependency, parameterName); + return dependency; + } + /// /// Gets a dependency of the sut. Can only be called after the dependency has been set, either explicitly with /// or automatically with . diff --git a/test/Common/Common.csproj b/test/Common/Common.csproj index 2f11798cef..3d1b6a6c3b 100644 --- a/test/Common/Common.csproj +++ b/test/Common/Common.csproj @@ -2,6 +2,8 @@ false Bit.Test.Common + + $(WarningsNotAsErrors);CA1305 diff --git a/test/Common/Helpers/CryptographyHelper.cs b/test/Common/Helpers/CryptographyHelper.cs new file mode 100644 index 0000000000..30dfb1a679 --- /dev/null +++ b/test/Common/Helpers/CryptographyHelper.cs @@ -0,0 +1,17 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Bit.Test.Common.Helpers; + +public class CryptographyHelper +{ + /// + /// Returns a hex-encoded, SHA256 hash for the given string + /// + public static string HashAndEncode(string text) + { + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(text)); + var hashEncoded = Convert.ToHexString(hashBytes).ToUpperInvariant(); + return hashEncoded; + } +} diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs index 09b112c43c..01ffb86a7d 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyFixtures.cs @@ -3,6 +3,7 @@ using AutoFixture; using AutoFixture.Xunit2; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; namespace Bit.Core.Test.AdminConsole.AutoFixture; @@ -10,19 +11,30 @@ internal class PolicyCustomization : ICustomization { public PolicyType Type { get; set; } public bool Enabled { get; set; } + public string? Data { get; set; } - public PolicyCustomization(PolicyType type, bool enabled) + public PolicyCustomization(PolicyType type, bool enabled, string? data) { Type = type; Enabled = enabled; + Data = data; } public void Customize(IFixture fixture) { + var orgId = Guid.NewGuid(); + fixture.Customize(composer => composer - .With(o => o.OrganizationId, Guid.NewGuid()) + .With(o => o.OrganizationId, orgId) .With(o => o.Type, Type) - .With(o => o.Enabled, Enabled)); + .With(o => o.Enabled, Enabled) + .With(o => o.Data, Data)); + + fixture.Customize(composer => composer + .With(o => o.OrganizationId, orgId) + .With(o => o.Type, Type) + .With(o => o.Enabled, Enabled) + .With(o => o.Data, Data)); } } @@ -30,15 +42,17 @@ public class PolicyAttribute : CustomizeAttribute { private readonly PolicyType _type; private readonly bool _enabled; + private readonly string? _data; - public PolicyAttribute(PolicyType type, bool enabled = true) + public PolicyAttribute(PolicyType type, bool enabled = true, string? data = null) { _type = type; _enabled = enabled; + _data = data; } public override ICustomization GetCustomization(ParameterInfo parameter) { - return new PolicyCustomization(_type, _enabled); + return new PolicyCustomization(_type, _enabled, _data); } } diff --git a/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs b/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs index fed38fb182..aa0c2c80c3 100644 --- a/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs +++ b/test/Core.Test/AdminConsole/Entities/OrganizationTests.cs @@ -1,7 +1,10 @@ -using System.Text.Json; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; +using Bit.Core.Billing.Organizations.Models; using Bit.Test.Common.Helpers; using Xunit; @@ -96,4 +99,124 @@ public class OrganizationTests var host = Assert.Contains("Host", (IDictionary)duo.MetaData); Assert.Equal("Host_value", host); } + + [Fact] + public void UseDisableSmAdsForUsers_DefaultValue_IsFalse() + { + var organization = new Organization(); + + Assert.False(organization.UseDisableSmAdsForUsers); + } + + [Fact] + public void UseDisableSmAdsForUsers_CanBeSetToTrue() + { + var organization = new Organization + { + UseDisableSmAdsForUsers = true + }; + + Assert.True(organization.UseDisableSmAdsForUsers); + } + + [Fact] + public void UpdateFromLicense_AppliesAllLicenseProperties() + { + // This test ensures that when a new property is added to OrganizationLicense, + // it is also applied to the Organization in UpdateFromLicense(). + // This is the fourth step in the license synchronization pipeline: + // Property → Constant → Claim → Extraction → Application + + // 1. Get all public properties from OrganizationLicense + var licenseProperties = typeof(OrganizationLicense) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .ToHashSet(); + + // 2. Define properties that don't need to be applied to Organization + var excludedProperties = new HashSet + { + // Internal/computed properties + "SignatureBytes", // Computed from Signature property + "ValidLicenseVersion", // Internal property, not serialized + "CurrentLicenseFileVersion", // Constant field, not an instance property + "Hash", // Signature-related, not applied to org + "Signature", // Signature-related, not applied to org + "Token", // The JWT itself, not applied to org + "Version", // License version, not stored on org + + // Properties intentionally excluded from UpdateFromLicense + "Id", // Self-hosted org has its own unique Guid + "MaxStorageGb", // Not enforced for self-hosted (per comment in UpdateFromLicense) + + // Properties not stored on Organization model + "LicenseType", // Not a property on Organization + "InstallationId", // Not a property on Organization + "Issued", // Not a property on Organization + "Refresh", // Not a property on Organization + "ExpirationWithoutGracePeriod", // Not a property on Organization + "Trial", // Not a property on Organization + "Expires", // Mapped to ExpirationDate on Organization (different name) + + // Deprecated properties not applied + "LimitCollectionCreationDeletion", // Deprecated, not applied + "AllowAdminAccessToAllCollectionItems", // Deprecated, not applied + }; + + // 3. Get properties that should be applied + var propertiesThatShouldBeApplied = licenseProperties + .Except(excludedProperties) + .ToHashSet(); + + // 4. Read Organization.UpdateFromLicense source code + var organizationSourcePath = Path.Combine( + Directory.GetCurrentDirectory(), + "..", "..", "..", "..", "..", "src", "Core", "AdminConsole", "Entities", "Organization.cs"); + var sourceCode = File.ReadAllText(organizationSourcePath); + + // 5. Find all property assignments in UpdateFromLicense method + // Pattern matches: PropertyName = license.PropertyName + // This regex looks for assignments like "Name = license.Name" or "ExpirationDate = license.Expires" + var assignmentPattern = @"(\w+)\s*=\s*license\.(\w+)"; + var matches = Regex.Matches(sourceCode, assignmentPattern); + + var appliedProperties = new HashSet(); + foreach (Match match in matches) + { + // Get the license property name (right side of assignment) + var licensePropertyName = match.Groups[2].Value; + appliedProperties.Add(licensePropertyName); + } + + // Special case: Expires is mapped to ExpirationDate + if (appliedProperties.Contains("Expires")) + { + appliedProperties.Add("Expires"); // Already added, but being explicit + } + + // 6. Find missing applications + var missingApplications = propertiesThatShouldBeApplied + .Except(appliedProperties) + .OrderBy(p => p) + .ToList(); + + // 7. Build error message with guidance + var errorMessage = ""; + if (missingApplications.Any()) + { + errorMessage = $"The following OrganizationLicense properties are NOT applied to Organization in UpdateFromLicense():\n"; + errorMessage += string.Join("\n", missingApplications.Select(p => $" - {p}")); + errorMessage += "\n\nPlease add the following lines to Organization.UpdateFromLicense():\n"; + foreach (var prop in missingApplications) + { + errorMessage += $" {prop} = license.{prop};\n"; + } + errorMessage += "\nNote: If the property maps to a different name on Organization (like Expires → ExpirationDate), adjust accordingly."; + } + + // 8. Assert - if this fails, the error message guides the developer to add the application + Assert.True( + !missingApplications.Any(), + $"\n{errorMessage}"); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs index 88025301b6..3095907a22 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs @@ -1,14 +1,16 @@ using AutoFixture; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; 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 Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -29,11 +31,12 @@ public class AdminRecoverAccountCommandTests Organization organization, OrganizationUser organizationUser, User user, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, SutProvider sutProvider) { // Arrange SetupValidOrganization(sutProvider, organization); - SetupValidPolicy(sutProvider, organization); + SetupValidPolicy(sutProvider, organization, policy); SetupValidOrganizationUser(organizationUser, organization.Id); SetupValidUser(sutProvider, user, organizationUser); SetupSuccessfulPasswordUpdate(sutProvider, user, newMasterPassword); @@ -87,25 +90,18 @@ public class AdminRecoverAccountCommandTests Assert.Equal("Organization does not allow password reset.", exception.Message); } - public static IEnumerable InvalidPolicies => new object[][] - { - [new Policy { Type = PolicyType.ResetPassword, Enabled = false }], [null] - }; - [Theory] - [BitMemberAutoData(nameof(InvalidPolicies))] + [BitAutoData] public async Task RecoverAccountAsync_InvalidPolicy_ThrowsBadRequest( - Policy resetPasswordPolicy, string newMasterPassword, string key, Organization organization, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { // Arrange SetupValidOrganization(sutProvider, organization); - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword) - .Returns(resetPasswordPolicy); + SetupValidPolicy(sutProvider, organization, policy); // Act & Assert var exception = await Assert.ThrowsAsync(() => @@ -171,11 +167,12 @@ public class AdminRecoverAccountCommandTests Organization organization, string newMasterPassword, string key, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, SutProvider sutProvider) { // Arrange SetupValidOrganization(sutProvider, organization); - SetupValidPolicy(sutProvider, organization); + SetupValidPolicy(sutProvider, organization, policy); // Act & Assert var exception = await Assert.ThrowsAsync(() => @@ -190,11 +187,12 @@ public class AdminRecoverAccountCommandTests string key, Organization organization, OrganizationUser organizationUser, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, SutProvider sutProvider) { // Arrange SetupValidOrganization(sutProvider, organization); - SetupValidPolicy(sutProvider, organization); + SetupValidPolicy(sutProvider, organization, policy); SetupValidOrganizationUser(organizationUser, organization.Id); sutProvider.GetDependency() .GetUserByIdAsync(organizationUser.UserId!.Value) @@ -213,11 +211,12 @@ public class AdminRecoverAccountCommandTests Organization organization, OrganizationUser organizationUser, User user, + [Policy(PolicyType.ResetPassword, true)] PolicyStatus policy, SutProvider sutProvider) { // Arrange SetupValidOrganization(sutProvider, organization); - SetupValidPolicy(sutProvider, organization); + SetupValidPolicy(sutProvider, organization, policy); SetupValidOrganizationUser(organizationUser, organization.Id); user.UsesKeyConnector = true; sutProvider.GetDependency() @@ -238,11 +237,10 @@ public class AdminRecoverAccountCommandTests .Returns(organization); } - private static void SetupValidPolicy(SutProvider sutProvider, Organization organization) + private static void SetupValidPolicy(SutProvider sutProvider, Organization organization, PolicyStatus policy) { - var policy = new Policy { Type = PolicyType.ResetPassword, Enabled = true }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.ResetPassword) .Returns(policy); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs index c3fb52ecbe..50e40b9803 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmOrganizationUsersValidatorTests.cs @@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Entities; @@ -120,7 +119,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: true, planType: PlanType.EnterpriseAnnually)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, User user, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = user.Id; @@ -137,8 +136,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() @@ -280,7 +279,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, Guid userId, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = userId; @@ -303,8 +302,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests PolicyType = PolicyType.TwoFactorAuthentication }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() @@ -334,7 +333,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, User user, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = user.Id; @@ -351,8 +350,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() @@ -389,7 +388,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, User user, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = user.Id; @@ -406,8 +405,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() @@ -448,7 +447,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, User user, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = user.Id; @@ -465,8 +464,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() @@ -501,7 +500,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests SutProvider sutProvider, Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, - Guid userId) + Guid userId, + [Policy(PolicyType.AutomaticUserConfirmation, false)] PolicyStatus policy) { // Arrange organizationUser.UserId = userId; @@ -518,9 +518,9 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) - .Returns((Policy)null); + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + .Returns(policy); sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Any>()) @@ -545,7 +545,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: false)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, Guid userId, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = userId; @@ -562,8 +562,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() @@ -589,7 +589,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests [Organization(useAutomaticUserConfirmation: true)] Organization organization, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser, User user, - [Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy) + [Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy) { // Arrange organizationUser.UserId = user.Id; @@ -606,8 +606,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests Key = "test-key" }; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation) + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation) .Returns(autoConfirmPolicy); sutProvider.GetDependency() diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs index 1035d5c578..252fb89c87 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs @@ -3,13 +3,13 @@ using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -203,14 +203,10 @@ public class AutomaticallyConfirmUsersCommandTests await sutProvider.GetDependency() .Received(1) - .CreateAsync( - Arg.Is(c => - c.OrganizationId == organization.Id && - c.Name == defaultCollectionName && - c.Type == CollectionType.DefaultUserCollection), - Arg.Is>(groups => groups == null), - Arg.Is>(access => - access.FirstOrDefault(x => x.Id == organizationUser.Id && x.Manage) != null)); + .CreateDefaultCollectionsAsync( + organization.Id, + Arg.Is>(ids => ids.Single() == organizationUser.Id), + defaultCollectionName); } [Theory] @@ -252,9 +248,7 @@ public class AutomaticallyConfirmUsersCommandTests await sutProvider.GetDependency() .DidNotReceive() - .CreateAsync(Arg.Any(), - Arg.Any>(), - Arg.Any>()); + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory] @@ -290,9 +284,7 @@ public class AutomaticallyConfirmUsersCommandTests var collectionException = new Exception("Collection creation failed"); sutProvider.GetDependency() - .CreateAsync(Arg.Any(), - Arg.Any>(), - Arg.Any>()) + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()) .ThrowsAsync(collectionException); // Act @@ -727,4 +719,54 @@ public class AutomaticallyConfirmUsersCommandTests Arg.Any>(), organization.Id.ToString()); } + + [Theory] + [BitAutoData] + public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOn_UsesNewMailer( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + const bool accessSecretsManager = true; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) + .Returns(true); + + // Act + await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(organization, userEmail, accessSecretsManager); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendConfirmationAsync(organization, userEmail, accessSecretsManager); + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationConfirmedEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOff_UsesLegacyMailService( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + const bool accessSecretsManager = false; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) + .Returns(false); + + // Act + await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(organization, userEmail, accessSecretsManager); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationConfirmedEmailAsync(organization.Name, userEmail, accessSecretsManager); + await sutProvider.GetDependency() + .DidNotReceive() + .SendConfirmationAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs index 5528ecb2a2..6643f26eb5 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -12,7 +13,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Platform.Push; using Bit.Core.Repositories; @@ -462,7 +462,7 @@ public class ConfirmOrganizationUserCommandTests } [Theory, BitAutoData] - public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithOrganizationDataOwnershipPolicyApplicable_WithValidCollectionName_CreatesDefaultCollection( + public async Task ConfirmUserAsync_WithOrganizationDataOwnershipPolicyApplicable_WithValidCollectionName_CreatesDefaultCollection( Organization organization, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, string key, string collectionName, SutProvider sutProvider) @@ -475,8 +475,6 @@ public class ConfirmOrganizationUserCommandTests sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true); - var policyDetails = new PolicyDetails { OrganizationId = organization.Id, @@ -494,19 +492,14 @@ public class ConfirmOrganizationUserCommandTests await sutProvider.GetDependency() .Received(1) - .CreateAsync( - Arg.Is(c => - c.Name == collectionName && - c.OrganizationId == organization.Id && - c.Type == CollectionType.DefaultUserCollection), - Arg.Any>(), - Arg.Is>(cu => - cu.Single().Id == orgUser.Id && - cu.Single().Manage)); + .CreateDefaultCollectionsAsync( + organization.Id, + Arg.Is>(ids => ids.Single() == orgUser.Id), + collectionName); } [Theory, BitAutoData] - public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithOrganizationDataOwnershipPolicyApplicable_WithInvalidCollectionName_DoesNotCreateDefaultCollection( + public async Task ConfirmUserAsync_WithOrganizationDataOwnershipPolicyApplicable_WithInvalidCollectionName_DoesNotCreateDefaultCollection( Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, string key, SutProvider sutProvider) @@ -519,17 +512,15 @@ public class ConfirmOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true); - await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, ""); await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] - public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithOrganizationDataOwnershipPolicyNotApplicable_DoesNotCreateDefaultCollection( + public async Task ConfirmUserAsync_WithOrganizationDataOwnershipPolicyNotApplicable_DoesNotCreateDefaultCollection( Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted, OrganizationUserType.Owner)] OrganizationUser orgUser, User user, string key, string collectionName, SutProvider sutProvider) @@ -541,26 +532,16 @@ public class ConfirmOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true); - var policyDetails = new PolicyDetails - { - OrganizationId = org.Id, - OrganizationUserId = orgUser.Id, - IsProvider = false, - OrganizationUserStatus = orgUser.Status, - OrganizationUserType = orgUser.Type, - PolicyType = PolicyType.OrganizationDataOwnership - }; sutProvider.GetDependency() .GetAsync(orgUser.UserId!.Value) - .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [policyDetails])); + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [])); await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName); await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] @@ -814,4 +795,52 @@ public class ConfirmOrganizationUserCommandTests Assert.Empty(result[1].Item2); Assert.Equal(new OtherOrganizationDoesNotAllowOtherMembership().Message, result[2].Item2); } + + [Theory, BitAutoData] + public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOn_UsesNewMailer( + Organization org, + string userEmail, + SutProvider sutProvider) + { + // Arrange + const bool accessSecretsManager = true; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) + .Returns(true); + + // Act + await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(org, userEmail, accessSecretsManager); + + // Assert - verify new mailer is called, not legacy mail service + await sutProvider.GetDependency() + .Received(1) + .SendConfirmationAsync(org, userEmail, accessSecretsManager); + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationConfirmedEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOff_UsesLegacyMailService( + Organization org, + string userEmail, + SutProvider sutProvider) + { + // Arrange + const bool accessSecretsManager = false; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) + .Returns(false); + + // Act + await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(org, userEmail, accessSecretsManager); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationConfirmedEmailAsync(org.DisplayName(), userEmail, accessSecretsManager); + await sutProvider.GetDependency() + .DidNotReceive() + .SendConfirmationAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs index 23c1a32c03..ddede2d191 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs @@ -1,7 +1,9 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; @@ -9,6 +11,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Models.Mail; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Tokens; using Bit.Test.Common.AutoFixture; @@ -31,6 +34,7 @@ public class SendOrganizationInvitesCommandTests Organization organization, SsoConfig ssoConfig, OrganizationUser invite, + [Policy(PolicyType.RequireSso, false)] PolicyStatus policy, SutProvider sutProvider) { // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks @@ -45,7 +49,9 @@ public class SendOrganizationInvitesCommandTests sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig); // Return null policy to mimic new org that's never turned on the require sso policy - sutProvider.GetDependency().GetManyByOrganizationIdAsync(organization.Id).ReturnsNull(); + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.RequireSso) + .Returns(policy); // Mock tokenable factory to return a token that expires in 5 days sutProvider.GetDependency() diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommandTests.cs new file mode 100644 index 0000000000..9e8ecb76d4 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationConfirmation/SendOrganizationConfirmationCommandTests.cs @@ -0,0 +1,245 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; +using Bit.Core.Billing.Enums; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; + +[SutProviderCustomize] +public class SendOrganizationConfirmationCommandTests +{ + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_EnterpriseOrganization_SendsEnterpriseTeamsEmail( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually; + organization.Name = "Test Enterprise Org"; + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.Contains(userEmail) && + mail.ToEmails.Count() == 1 && + mail.View.OrganizationName == organization.Name && + mail.Subject == GetSubject(organization.Name))); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_TeamsOrganization_SendsEnterpriseTeamsEmail( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.TeamsAnnually; + organization.Name = "Test Teams Org"; + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.Contains(userEmail) && + mail.ToEmails.Count() == 1 && + mail.View.OrganizationName == organization.Name && + mail.Subject == GetSubject(organization.Name))); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_FamilyOrganization_SendsFamilyFreeEmail( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.FamiliesAnnually; + organization.Name = "Test Family Org"; + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.Contains(userEmail) && + mail.ToEmails.Count() == 1 && + mail.View.OrganizationName == organization.Name && + mail.Subject == GetSubject(organization.Name))); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_FreeOrganization_SendsFamilyFreeEmail( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.Free; + organization.Name = "Test Free Org"; + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.Contains(userEmail) && + mail.ToEmails.Count() == 1 && + mail.View.OrganizationName == organization.Name && + mail.Subject == GetSubject(organization.Name))); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationsAsync_MultipleUsers_SendsSingleEmail( + Organization organization, + List userEmails, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually; + organization.Name = "Test Enterprise Org"; + + // Act + await sutProvider.Sut.SendConfirmationsAsync(organization, userEmails, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.SequenceEqual(userEmails) && + mail.View.OrganizationName == organization.Name)); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationsAsync_EmptyUserList_DoesNotSendEmail( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually; + organization.Name = "Test Enterprise Org"; + + // Act + await sutProvider.Sut.SendConfirmationsAsync(organization, [], false); + + // Assert + await sutProvider.GetDependency().DidNotReceive() + .SendEmail(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .SendEmail(Arg.Any()); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_HtmlEncodedOrganizationName_DecodesNameCorrectly( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually; + organization.Name = "Test & Company"; + var expectedDecodedName = "Test & Company"; + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.View.OrganizationName == expectedDecodedName)); + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_AllEnterpriseTeamsPlanTypes_SendsEnterpriseTeamsEmail( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Test all Enterprise and Teams plan types + var enterpriseTeamsPlanTypes = new[] + { + PlanType.TeamsMonthly2019, PlanType.TeamsAnnually2019, + PlanType.TeamsMonthly2020, PlanType.TeamsAnnually2020, + PlanType.TeamsMonthly2023, PlanType.TeamsAnnually2023, + PlanType.TeamsStarter2023, PlanType.TeamsMonthly, + PlanType.TeamsAnnually, PlanType.TeamsStarter, + PlanType.EnterpriseMonthly2019, PlanType.EnterpriseAnnually2019, + PlanType.EnterpriseMonthly2020, PlanType.EnterpriseAnnually2020, + PlanType.EnterpriseMonthly2023, PlanType.EnterpriseAnnually2023, + PlanType.EnterpriseMonthly, PlanType.EnterpriseAnnually + }; + + foreach (var planType in enterpriseTeamsPlanTypes) + { + // Arrange + organization.PlanType = planType; + organization.Name = "Test Org"; + sutProvider.GetDependency().ClearReceivedCalls(); + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .SendEmail(Arg.Any()); + } + } + + [Theory] + [OrganizationCustomize, BitAutoData] + public async Task SendConfirmationAsync_AllFamilyFreePlanTypes_SendsFamilyFreeEmail( + Organization organization, + string userEmail, + SutProvider sutProvider) + { + // Test all Family, Free, and Custom plan types + var familyFreePlanTypes = new[] + { + PlanType.Free, PlanType.FamiliesAnnually2019, + PlanType.FamiliesAnnually2025, PlanType.FamiliesAnnually, + PlanType.Custom + }; + + foreach (var planType in familyFreePlanTypes) + { + // Arrange + organization.PlanType = planType; + organization.Name = "Test Org"; + sutProvider.GetDependency().ClearReceivedCalls(); + + // Act + await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false); + + // Assert + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .SendEmail(Arg.Any()); + } + } + + private static string GetSubject(string organizationName) => $"You can now access items from {organizationName}"; + +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index 4fa5e92abe..29c996cee9 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -37,7 +37,7 @@ public class RestoreOrganizationUserCommandTests Sponsored = 0, Users = 1 }); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); await sutProvider.GetDependency() .Received(1) @@ -81,7 +81,7 @@ public class RestoreOrganizationUserCommandTests RestoreUser_Setup(organization, owner, organizationUser, sutProvider); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("you cannot restore yourself", exception.Message.ToLowerInvariant()); @@ -107,7 +107,7 @@ public class RestoreOrganizationUserCommandTests RestoreUser_Setup(organization, restoringUser, organizationUser, sutProvider); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, restoringUser.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, restoringUser.Id, null)); Assert.Contains("only owners can restore other owners", exception.Message.ToLowerInvariant()); @@ -133,7 +133,7 @@ public class RestoreOrganizationUserCommandTests RestoreUser_Setup(organization, owner, organizationUser, sutProvider); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("already active", exception.Message.ToLowerInvariant()); @@ -172,7 +172,7 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("test@bitwarden.com belongs to an organization that doesn't allow them to join multiple organizations", exception.Message.ToLowerInvariant()); @@ -216,7 +216,7 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant()); @@ -272,7 +272,7 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant()); @@ -309,7 +309,7 @@ public class RestoreOrganizationUserCommandTests Sponsored = 0, Users = 1 }); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); await sutProvider.GetDependency() .Received(1) @@ -349,7 +349,7 @@ public class RestoreOrganizationUserCommandTests } ])); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); await sutProvider.GetDependency() .Received(1) @@ -395,7 +395,7 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("test@bitwarden.com is not compliant with the single organization policy", exception.Message.ToLowerInvariant()); @@ -447,7 +447,7 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("test@bitwarden.com is not compliant with the single organization and two-step login policy", exception.Message.ToLowerInvariant()); @@ -509,7 +509,7 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Contains("test@bitwarden.com is not compliant with the single organization and two-step login policy", exception.Message.ToLowerInvariant()); @@ -548,7 +548,7 @@ public class RestoreOrganizationUserCommandTests .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); await sutProvider.GetDependency() .Received(1) @@ -599,7 +599,7 @@ public class RestoreOrganizationUserCommandTests .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); Assert.Equal("User is an owner/admin of another free organization. Please have them upgrade to a paid plan to restore their account.", exception.Message); } @@ -651,7 +651,7 @@ public class RestoreOrganizationUserCommandTests .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); await organizationUserRepository .Received(1) @@ -707,7 +707,7 @@ public class RestoreOrganizationUserCommandTests .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); - await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id); + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); await organizationUserRepository .Received(1) @@ -715,6 +715,39 @@ public class RestoreOrganizationUserCommandTests Arg.Is(x => x != OrganizationUserStatusType.Revoked)); } + [Theory, BitAutoData] + public async Task RestoreUser_InvitedUserInFreeOrganization_Success( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + organization.PlanType = PlanType.Free; + organizationUser.UserId = null; + organizationUser.Key = null; + organizationUser.Status = OrganizationUserStatusType.Revoked; + + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts + { + Sponsored = 0, + Users = 1 + }); + + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, ""); + + await sutProvider.GetDependency() + .Received(1) + .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Invited); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .PushSyncOrgKeysAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task RestoreUsers_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, @@ -749,7 +782,7 @@ public class RestoreOrganizationUserCommandTests }); // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, new[] { orgUser1.Id, orgUser2.Id }, owner.Id, userService); + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, new[] { orgUser1.Id, orgUser2.Id }, owner.Id, userService, null); // Assert Assert.Equal(2, result.Count); @@ -810,7 +843,7 @@ public class RestoreOrganizationUserCommandTests }); // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService); + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService, null); // Assert Assert.Equal(3, result.Count); @@ -881,7 +914,7 @@ public class RestoreOrganizationUserCommandTests }); // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService); + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService, null); // Assert Assert.Equal(3, result.Count); @@ -959,7 +992,7 @@ public class RestoreOrganizationUserCommandTests }); // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService); + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id, orgUser2.Id, orgUser3.Id], owner.Id, userService, null); // Assert Assert.Equal(3, result.Count); @@ -1023,7 +1056,7 @@ public class RestoreOrganizationUserCommandTests }); // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService); + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService, null); // Assert Assert.Single(result); @@ -1074,7 +1107,7 @@ public class RestoreOrganizationUserCommandTests .Returns([new OrganizationUserPolicyDetails { OrganizationId = organization.Id, PolicyType = PolicyType.TwoFactorAuthentication }]); // Act - var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService); + var result = await sutProvider.Sut.RestoreUsersAsync(organization.Id, [orgUser1.Id], owner.Id, userService, null); Assert.Single(result); Assert.Equal(string.Empty, result[0].Item2); @@ -1105,5 +1138,408 @@ public class RestoreOrganizationUserCommandTests sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(requestingOrganizationUser != null && requestingOrganizationUser.Type is OrganizationUserType.Owner); sutProvider.GetDependency().ManageUsers(organization.Id).Returns(requestingOrganizationUser != null && (requestingOrganizationUser.Type is OrganizationUserType.Owner or OrganizationUserType.Admin)); + + // Setup default disabled OrganizationDataOwnershipPolicyRequirement for any user + sutProvider.GetDependency() + .GetAsync(Arg.Any()) + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [])); } + + private static void SetupOrganizationDataOwnershipPolicy( + SutProvider sutProvider, + Guid userId, + Guid organizationId, + OrganizationUserStatusType orgUserStatus, + bool policyEnabled) + { + var policyDetails = policyEnabled + ? new List + { + new() + { + OrganizationId = organizationId, + OrganizationUserId = Guid.NewGuid(), + OrganizationUserStatus = orgUserStatus, + PolicyType = PolicyType.OrganizationDataOwnership + } + } + : new List(); + + var policyRequirement = new OrganizationDataOwnershipPolicyRequirement( + policyEnabled ? OrganizationDataOwnershipState.Enabled : OrganizationDataOwnershipState.Disabled, + policyDetails); + + sutProvider.GetDependency() + .GetAsync(userId) + .Returns(policyRequirement); + } + + #region Single User Restore - Default Collection Tests + + [Theory, BitAutoData] + public async Task RestoreUser_WithDataOwnershipPolicyEnabled_AndConfirmedUser_CreatesDefaultCollection( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + string defaultCollectionName, + SutProvider sutProvider) + { + // Arrange + organizationUser.Email = null; // This causes user to restore to Confirmed status + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + SetupOrganizationDataOwnershipPolicy( + sutProvider, + organizationUser.UserId!.Value, + organization.Id, + OrganizationUserStatusType.Revoked, + policyEnabled: true); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, defaultCollectionName); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .CreateDefaultCollectionsAsync( + organization.Id, + Arg.Is>(ids => ids.Single() == organizationUser.Id), + defaultCollectionName); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithDataOwnershipPolicyDisabled_DoesNotCreateDefaultCollection( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + string defaultCollectionName, + SutProvider sutProvider) + { + // Arrange + organizationUser.Email = null; // This causes user to restore to Confirmed status + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + SetupOrganizationDataOwnershipPolicy( + sutProvider, + organizationUser.UserId!.Value, + organization.Id, + OrganizationUserStatusType.Revoked, + policyEnabled: false); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, defaultCollectionName); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithNullDefaultCollectionName_DoesNotCreateDefaultCollection( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.Email = null; // This causes user to restore to Confirmed status + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + SetupOrganizationDataOwnershipPolicy( + sutProvider, + organizationUser.UserId!.Value, + organization.Id, + OrganizationUserStatusType.Revoked, + policyEnabled: true); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory] + [BitAutoData("")] + [BitAutoData(" ")] + public async Task RestoreUser_WithEmptyOrWhitespaceDefaultCollectionName_DoesNotCreateDefaultCollection( + string defaultCollectionName, + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.Email = null; // This causes user to restore to Confirmed status + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + SetupOrganizationDataOwnershipPolicy( + sutProvider, + organizationUser.UserId!.Value, + organization.Id, + OrganizationUserStatusType.Revoked, + policyEnabled: true); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, defaultCollectionName); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_UserRestoredToInvitedStatus_DoesNotCreateDefaultCollection( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + string defaultCollectionName, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually; // Non-Free plan to avoid ownership check requiring UserId + organizationUser.Email = "test@example.com"; // Non-null email means user restores to Invited status + organizationUser.UserId = null; // User not linked to account yet + organizationUser.Key = null; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, defaultCollectionName); + + // Assert - User was restored to Invited status, so no collection should be created + await sutProvider.GetDependency() + .DidNotReceive() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithNoUserId_DoesNotCreateDefaultCollection( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + string defaultCollectionName, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually; // Non-Free plan to avoid ownership check requiring UserId + organizationUser.UserId = null; // No linked user account + organizationUser.Email = "test@example.com"; + organizationUser.Key = null; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, defaultCollectionName); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + #endregion + + #region Bulk User Restore - Default Collection Tests + + [Theory, BitAutoData] + public async Task RestoreUsers_Bulk_WithDataOwnershipPolicy_CreatesCollectionsForEligibleUsers( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, + string defaultCollectionName, + SutProvider sutProvider) + { + // Arrange + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + var organizationUserRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + // orgUser1: Will restore to Confirmed (Email = null) + orgUser1.Email = null; + orgUser1.OrganizationId = organization.Id; + + // orgUser2: Will restore to Invited (Email not null) + orgUser2.Email = "test@example.com"; + orgUser2.UserId = null; + orgUser2.Key = null; + orgUser2.OrganizationId = organization.Id; + + organizationUserRepository + .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id))) + .Returns([orgUser1, orgUser2]); + + // Setup bulk policy query - returns org user IDs with policy enabled + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organization.Id) + .Returns([orgUser1.Id]); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> + { + (orgUser1.UserId!.Value, true) + }); + + // Act + var result = await sutProvider.Sut.RestoreUsersAsync( + organization.Id, + [orgUser1.Id, orgUser2.Id], + owner.Id, + userService, + defaultCollectionName); + + // Assert - Only orgUser1 should have a collection created (Confirmed with policy enabled) + await sutProvider.GetDependency() + .Received(1) + .CreateDefaultCollectionsAsync( + organization.Id, + Arg.Is>(ids => ids.Single() == orgUser1.Id), + defaultCollectionName); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_Bulk_WithMixedPolicyStates_OnlyCreatesForEnabledPolicy( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, + string defaultCollectionName, + SutProvider sutProvider) + { + // Arrange + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + var organizationUserRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + // Both users will restore to Confirmed + orgUser1.Email = null; + orgUser1.OrganizationId = organization.Id; + orgUser2.Email = null; + orgUser2.OrganizationId = organization.Id; + + organizationUserRepository + .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id))) + .Returns([orgUser1, orgUser2]); + + // Setup bulk policy query - only orgUser1 has policy enabled + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organization.Id) + .Returns([orgUser1.Id]); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> + { + (orgUser1.UserId!.Value, true), + (orgUser2.UserId!.Value, true) + }); + + // Act + var result = await sutProvider.Sut.RestoreUsersAsync( + organization.Id, + [orgUser1.Id, orgUser2.Id], + owner.Id, + userService, + defaultCollectionName); + + // Assert - Only orgUser1 should have a collection created (policy enabled) + await sutProvider.GetDependency() + .Received(1) + .CreateDefaultCollectionsAsync( + organization.Id, + Arg.Is>(ids => ids.Single() == orgUser1.Id), + defaultCollectionName); + } + + [Theory, BitAutoData] + public async Task RestoreUsers_Bulk_WithNullCollectionName_DoesNotCreateAnyCollections( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser orgUser2, + SutProvider sutProvider) + { + // Arrange + RestoreUser_Setup(organization, owner, orgUser1, sutProvider); + var organizationUserRepository = sutProvider.GetDependency(); + var userService = Substitute.For(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.DefaultUserCollectionRestore) + .Returns(true); + + // Both users will restore to Confirmed + orgUser1.Email = null; + orgUser1.OrganizationId = organization.Id; + orgUser2.Email = null; + orgUser2.OrganizationId = organization.Id; + + organizationUserRepository + .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id))) + .Returns([orgUser1, orgUser2]); + + // Setup bulk policy query - both users have policy enabled + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organization.Id) + .Returns([orgUser1.Id, orgUser2.Id]); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> + { + (orgUser1.UserId!.Value, true), + (orgUser2.UserId!.Value, true) + }); + + // Act + var result = await sutProvider.Sut.RestoreUsersAsync( + organization.Id, + [orgUser1.Id, orgUser2.Id], + owner.Id, + userService, + null); // Null collection name + + // Assert - No collections should be created + await sutProvider.GetDependency() + .DidNotReceive() + .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + #endregion } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommandTests.cs new file mode 100644 index 0000000000..9b9007e784 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/SelfRevokeUser/SelfRevokeOrganizationUserCommandTests.cs @@ -0,0 +1,233 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser; + +[SutProviderCustomize] +public class SelfRevokeOrganizationUserCommandTests +{ + [Theory] + [BitAutoData(OrganizationUserType.User)] + [BitAutoData(OrganizationUserType.Custom)] + [BitAutoData(OrganizationUserType.Admin)] + public async Task SelfRevokeUser_Success( + OrganizationUserType userType, + Guid organizationId, + Guid userId, + [OrganizationUser(OrganizationUserStatusType.Confirmed)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.Type = userType; + organizationUser.OrganizationId = organizationId; + organizationUser.UserId = userId; + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(organizationUser); + + // Create policy requirement with confirmed user + var policyDetails = new List + { + new() + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed, + OrganizationUserType = userType, + PolicyType = PolicyType.OrganizationDataOwnership + } + }; + var policyRequirement = new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Enabled, + policyDetails); + + sutProvider.GetDependency() + .GetAsync(userId) + .Returns(policyRequirement); + + // Act + var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId); + + // Assert + Assert.True(result.IsSuccess); + + await sutProvider.GetDependency() + .Received(1) + .RevokeAsync(organizationUser.Id); + + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_SelfRevoked); + + await sutProvider.GetDependency() + .Received(1) + .PushSyncOrgKeysAsync(userId); + } + + [Theory, BitAutoData] + public async Task SelfRevokeUser_WhenUserNotFound_ReturnsNotFoundError( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns((OrganizationUser)null); + + // Act + var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task SelfRevokeUser_WhenNotEligible_ReturnsBadRequestError( + Guid organizationId, + Guid userId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.OrganizationId = organizationId; + organizationUser.UserId = userId; + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(organizationUser); + + // Policy requirement with no policies (disabled) + var policyRequirement = new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Disabled, + Enumerable.Empty()); + + sutProvider.GetDependency() + .GetAsync(userId) + .Returns(policyRequirement); + + // Act + var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task SelfRevokeUser_WhenLastOwner_ReturnsBadRequestError( + Guid organizationId, + Guid userId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.OrganizationId = organizationId; + organizationUser.UserId = userId; + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(organizationUser); + + // Create policy requirement with confirmed owner + var policyDetails = new List + { + new() + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed, + OrganizationUserType = OrganizationUserType.Owner, + PolicyType = PolicyType.OrganizationDataOwnership + } + }; + var policyRequirement = new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Enabled, + policyDetails); + + sutProvider.GetDependency() + .GetAsync(userId) + .Returns(policyRequirement); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>(), true) + .Returns(false); + + // Act + var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId); + + // Assert + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task SelfRevokeUser_WhenOwnerButNotLastOwner_Success( + Guid organizationId, + Guid userId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.OrganizationId = organizationId; + organizationUser.UserId = userId; + + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationId, userId) + .Returns(organizationUser); + + // Create policy requirement with confirmed owner + var policyDetails = new List + { + new() + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed, + OrganizationUserType = OrganizationUserType.Owner, + PolicyType = PolicyType.OrganizationDataOwnership + } + }; + var policyRequirement = new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Enabled, + policyDetails); + + sutProvider.GetDependency() + .GetAsync(userId) + .Returns(policyRequirement); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>(), true) + .Returns(true); + + // Act + var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId); + + // Assert + Assert.True(result.IsSuccess); + + await sutProvider.GetDependency() + .Received(1) + .RevokeAsync(organizationUser.Id); + + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_SelfRevoked); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs index ab4788c808..c580052f2a 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs @@ -36,6 +36,73 @@ public class OrganizationDataOwnershipPolicyRequirementFactoryTests Assert.Equal(PolicyType.OrganizationDataOwnership, sutProvider.Sut.PolicyType); } + [Theory, BitAutoData] + public void EligibleForSelfRevoke_WithConfirmedUser_ReturnsTrue( + Guid organizationId, + [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails[] policies, + SutProvider sutProvider) + { + // Arrange + policies[0].OrganizationId = organizationId; + var requirement = sutProvider.Sut.Create(policies); + + // Act + var result = requirement.EligibleForSelfRevoke(organizationId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public void EligibleForSelfRevoke_WithInvitedUser_ReturnsFalse( + Guid organizationId, + [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Invited)] PolicyDetails[] policies, + SutProvider sutProvider) + { + // Arrange + policies[0].OrganizationId = organizationId; + var requirement = sutProvider.Sut.Create(policies); + + // Act + var result = requirement.EligibleForSelfRevoke(organizationId); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public void EligibleForSelfRevoke_WithNoPolicies_ReturnsFalse( + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + var requirement = sutProvider.Sut.Create([]); + + // Act + var result = requirement.EligibleForSelfRevoke(organizationId); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public void EligibleForSelfRevoke_WithDifferentOrganization_ReturnsFalse( + Guid organizationId, + Guid differentOrganizationId, + [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails[] policies, + SutProvider sutProvider) + { + // Arrange + policies[0].OrganizationId = differentOrganizationId; + var requirement = sutProvider.Sut.Create(policies); + + // Act + var result = requirement.EligibleForSelfRevoke(organizationId); + + // Assert + Assert.False(result); + } + [Theory, BitAutoData] public void GetDefaultCollectionRequestOnPolicyEnable_WithConfirmedUser_ReturnsTrue( [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails[] policies, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs index 3c9fd9a9e9..e2c9de4d6f 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs @@ -283,7 +283,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests OrganizationId = policyUpdate.OrganizationId, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Invited, - UserId = Guid.NewGuid(), + UserId = null, Email = "invited@example.com" }; @@ -302,6 +302,56 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests Assert.True(string.IsNullOrEmpty(result)); } + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_MixedUsersWithNullUserId_HandlesCorrectly( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + Guid confirmedUserId, + SutProvider sutProvider) + { + // Arrange + var invitedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Invited, + UserId = null, + Email = "invited@example.com" + }; + + var confirmedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = confirmedUserId, + Email = "confirmed@example.com" + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([invitedUser, confirmedUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + + await sutProvider.GetDependency() + .Received(1) + .GetManyByManyUsersAsync(Arg.Is>(ids => ids.Count() == 1 && ids.First() == confirmedUserId)); + } + [Theory, BitAutoData] public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck( [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs index e6677c8a23..dd2f1d76e8 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs @@ -6,7 +6,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -20,29 +19,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests { private const string _defaultUserCollectionName = "Default"; - [Theory, BitAutoData] - public async Task ExecuteSideEffectsAsync_FeatureFlagDisabled_DoesNothing( - [PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate, - [Policy(PolicyType.OrganizationDataOwnership, false)] Policy postUpdatedPolicy, - [Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState, - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) - .Returns(false); - - var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); - - // Act - await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); - - // Assert - await sutProvider.GetDependency() - .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); - } - [Theory, BitAutoData] public async Task ExecuteSideEffectsAsync_PolicyAlreadyEnabled_DoesNothing( [PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate, @@ -54,10 +30,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId; previousPolicyState.OrganizationId = policyUpdate.OrganizationId; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) - .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act @@ -66,7 +38,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .CreateDefaultCollectionsBulkAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] @@ -80,10 +52,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests previousPolicyState.OrganizationId = policyUpdate.OrganizationId; postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) - .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act @@ -92,7 +60,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .CreateDefaultCollectionsBulkAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] @@ -118,7 +86,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await collectionRepository .DidNotReceive() - .UpsertDefaultCollectionsAsync( + .CreateDefaultCollectionsBulkAsync( Arg.Any(), Arg.Any>(), Arg.Any()); @@ -204,10 +172,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Act await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); - // Assert + // Assert - Should call with all user IDs (repository does internal filtering) await collectionRepository .Received(1) - .UpsertDefaultCollectionsAsync( + .CreateDefaultCollectionsBulkAsync( policyUpdate.OrganizationId, Arg.Is>(ids => ids.Count() == 3), _defaultUserCollectionName); @@ -234,10 +202,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests previousPolicyState.OrganizationId = policyUpdate.OrganizationId; policyUpdate.Enabled = true; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) - .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, metadata); // Act @@ -246,7 +210,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .CreateDefaultCollectionsBulkAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } private static IPolicyRepository ArrangePolicyRepository(IEnumerable policyDetails) @@ -264,39 +228,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests IPolicyRepository policyRepository, ICollectionRepository collectionRepository) { - - var featureService = Substitute.For(); - featureService - .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) - .Returns(true); - - var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory], featureService); + var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory]); return sut; } - [Theory, BitAutoData] - public async Task ExecutePostUpsertSideEffectAsync_FeatureFlagDisabled_DoesNothing( - [PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate, - [Policy(PolicyType.OrganizationDataOwnership, false)] Policy postUpdatedPolicy, - [Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState, - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) - .Returns(false); - - var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); - - // Act - await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); - - // Assert - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpsertDefaultCollectionsAsync(default, default, default); - } - [Theory, BitAutoData] public async Task ExecutePostUpsertSideEffectAsync_PolicyAlreadyEnabled_DoesNothing( [PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate, @@ -308,10 +243,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId; previousPolicyState.OrganizationId = policyUpdate.OrganizationId; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) - .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act @@ -320,7 +251,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpsertDefaultCollectionsAsync(default, default, default); + .CreateDefaultCollectionsBulkAsync(default, default, default); } [Theory, BitAutoData] @@ -334,10 +265,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests previousPolicyState.OrganizationId = policyUpdate.OrganizationId; postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) - .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act @@ -346,7 +273,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpsertDefaultCollectionsAsync(default, default, default); + .CreateDefaultCollectionsBulkAsync(default, default, default); } [Theory, BitAutoData] @@ -372,7 +299,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await collectionRepository .DidNotReceiveWithAnyArgs() - .UpsertDefaultCollectionsAsync( + .CreateDefaultCollectionsBulkAsync( default, default, default); @@ -409,10 +336,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Act await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); - // Assert + // Assert - Should call with all user IDs (repository does internal filtering) await collectionRepository .Received(1) - .UpsertDefaultCollectionsAsync( + .CreateDefaultCollectionsBulkAsync( policyUpdate.OrganizationId, Arg.Is>(ids => ids.Count() == 3), _defaultUserCollectionName); @@ -432,10 +359,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests previousPolicyState.OrganizationId = policyUpdate.OrganizationId; policyUpdate.Enabled = true; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) - .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, metadata); // Act @@ -444,6 +367,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests // Assert await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .UpsertDefaultCollectionsAsync(default, default, default); + .CreateDefaultCollectionsBulkAsync(default, default, default); } } diff --git a/test/Core.Test/AdminConsole/Utilities/PolicyDataValidatorTests.cs b/test/Core.Test/AdminConsole/Utilities/PolicyDataValidatorTests.cs index 43725d23e0..dcc4ceb246 100644 --- a/test/Core.Test/AdminConsole/Utilities/PolicyDataValidatorTests.cs +++ b/test/Core.Test/AdminConsole/Utilities/PolicyDataValidatorTests.cs @@ -19,12 +19,17 @@ public class PolicyDataValidatorTests [Fact] public void ValidateAndSerialize_ValidData_ReturnsSerializedJson() { - var data = new Dictionary { { "minLength", 12 } }; + var data = new Dictionary + { + { "minLength", 12 }, + { "minComplexity", 4 } + }; var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); Assert.NotNull(result); Assert.Contains("\"minLength\":12", result); + Assert.Contains("\"minComplexity\":4", result); } [Fact] @@ -56,4 +61,122 @@ public class PolicyDataValidatorTests Assert.IsType(result); } + + [Fact] + public void ValidateAndSerialize_ExcessiveMinLength_ThrowsBadRequestException() + { + var data = new Dictionary { { "minLength", 129 } }; + + var exception = Assert.Throws(() => + PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword)); + + Assert.Contains("Invalid data for MasterPassword policy", exception.Message); + } + + [Fact] + public void ValidateAndSerialize_ExcessiveMinComplexity_ThrowsBadRequestException() + { + var data = new Dictionary { { "minComplexity", 5 } }; + + var exception = Assert.Throws(() => + PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword)); + + Assert.Contains("Invalid data for MasterPassword policy", exception.Message); + } + + [Fact] + public void ValidateAndSerialize_MinLengthAtMinimum_Succeeds() + { + var data = new Dictionary { { "minLength", 12 } }; + + var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); + + Assert.NotNull(result); + Assert.Contains("\"minLength\":12", result); + } + + [Fact] + public void ValidateAndSerialize_MinLengthAtMaximum_Succeeds() + { + var data = new Dictionary { { "minLength", 128 } }; + + var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); + + Assert.NotNull(result); + Assert.Contains("\"minLength\":128", result); + } + + [Fact] + public void ValidateAndSerialize_MinLengthBelowMinimum_ThrowsBadRequestException() + { + var data = new Dictionary { { "minLength", 11 } }; + + var exception = Assert.Throws(() => + PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword)); + + Assert.Contains("Invalid data for MasterPassword policy", exception.Message); + } + + [Fact] + public void ValidateAndSerialize_MinComplexityAtMinimum_Succeeds() + { + var data = new Dictionary { { "minComplexity", 0 } }; + + var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); + + Assert.NotNull(result); + Assert.Contains("\"minComplexity\":0", result); + } + + [Fact] + public void ValidateAndSerialize_MinComplexityAtMaximum_Succeeds() + { + var data = new Dictionary { { "minComplexity", 4 } }; + + var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); + + Assert.NotNull(result); + Assert.Contains("\"minComplexity\":4", result); + } + + [Fact] + public void ValidateAndSerialize_MinComplexityBelowMinimum_ThrowsBadRequestException() + { + var data = new Dictionary { { "minComplexity", -1 } }; + + var exception = Assert.Throws(() => + PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword)); + + Assert.Contains("Invalid data for MasterPassword policy", exception.Message); + } + + [Fact] + public void ValidateAndSerialize_NullMinLength_Succeeds() + { + var data = new Dictionary + { + { "minComplexity", 2 } + // minLength is omitted, should be null + }; + + var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); + + Assert.NotNull(result); + Assert.Contains("\"minComplexity\":2", result); + } + + [Fact] + public void ValidateAndSerialize_MultipleInvalidFields_ThrowsBadRequestException() + { + var data = new Dictionary + { + { "minLength", 200 }, + { "minComplexity", 10 } + }; + + var exception = Assert.Throws(() => + PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword)); + + Assert.Contains("Invalid data for MasterPassword policy", exception.Message); + } } diff --git a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs index 2f4d00a7fa..ca4378e6ec 100644 --- a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs +++ b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs @@ -2,9 +2,9 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; @@ -13,6 +13,7 @@ using Bit.Core.Auth.Services; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -163,7 +164,8 @@ public class SsoConfigServiceTests [Theory, BitAutoData] public async Task SaveAsync_KeyConnector_SingleOrgNotEnabled_Throws(SutProvider sutProvider, - Organization organization) + Organization organization, + [Policy(PolicyType.SingleOrg, false)] PolicyStatus policy) { var utcNow = DateTime.UtcNow; @@ -180,6 +182,9 @@ public class SsoConfigServiceTests RevisionDate = utcNow.AddDays(-10), }; + sutProvider.GetDependency().RunAsync( + Arg.Any(), PolicyType.SingleOrg).Returns(policy); + var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(ssoConfig, organization)); @@ -191,7 +196,9 @@ public class SsoConfigServiceTests [Theory, BitAutoData] public async Task SaveAsync_KeyConnector_SsoPolicyNotEnabled_Throws(SutProvider sutProvider, - Organization organization) + Organization organization, + [Policy(PolicyType.SingleOrg, true)] PolicyStatus singleOrgPolicy, + [Policy(PolicyType.RequireSso, false)] PolicyStatus requireSsoPolicy) { var utcNow = DateTime.UtcNow; @@ -208,11 +215,10 @@ public class SsoConfigServiceTests RevisionDate = utcNow.AddDays(-10), }; - sutProvider.GetDependency().GetByOrganizationIdTypeAsync( - Arg.Any(), PolicyType.SingleOrg).Returns(new Policy - { - Enabled = true - }); + sutProvider.GetDependency().RunAsync( + Arg.Any(), PolicyType.SingleOrg).Returns(singleOrgPolicy); + sutProvider.GetDependency().RunAsync( + Arg.Any(), PolicyType.RequireSso).Returns(requireSsoPolicy); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(ssoConfig, organization)); @@ -225,7 +231,8 @@ public class SsoConfigServiceTests [Theory, BitAutoData] public async Task SaveAsync_KeyConnector_SsoConfigNotEnabled_Throws(SutProvider sutProvider, - Organization organization) + Organization organization, + [Policy(PolicyType.SingleOrg, true)] PolicyStatus policy) { var utcNow = DateTime.UtcNow; @@ -242,11 +249,8 @@ public class SsoConfigServiceTests RevisionDate = utcNow.AddDays(-10), }; - sutProvider.GetDependency().GetByOrganizationIdTypeAsync( - Arg.Any(), Arg.Any()).Returns(new Policy - { - Enabled = true - }); + sutProvider.GetDependency().RunAsync( + Arg.Any(), Arg.Any()).Returns(policy); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(ssoConfig, organization)); @@ -259,7 +263,8 @@ public class SsoConfigServiceTests [Theory, BitAutoData] public async Task SaveAsync_KeyConnector_KeyConnectorAbilityNotEnabled_Throws(SutProvider sutProvider, - Organization organization) + Organization organization, + [Policy(PolicyType.SingleOrg, true)] PolicyStatus policy) { var utcNow = DateTime.UtcNow; @@ -277,11 +282,8 @@ public class SsoConfigServiceTests RevisionDate = utcNow.AddDays(-10), }; - sutProvider.GetDependency().GetByOrganizationIdTypeAsync( - Arg.Any(), Arg.Any()).Returns(new Policy - { - Enabled = true, - }); + sutProvider.GetDependency().RunAsync( + Arg.Any(), Arg.Any()).Returns(policy); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveAsync(ssoConfig, organization)); @@ -294,7 +296,8 @@ public class SsoConfigServiceTests [Theory, BitAutoData] public async Task SaveAsync_KeyConnector_Success(SutProvider sutProvider, - Organization organization) + Organization organization, + [Policy(PolicyType.SingleOrg, true)] PolicyStatus policy) { var utcNow = DateTime.UtcNow; @@ -312,11 +315,8 @@ public class SsoConfigServiceTests RevisionDate = utcNow.AddDays(-10), }; - sutProvider.GetDependency().GetByOrganizationIdTypeAsync( - Arg.Any(), Arg.Any()).Returns(new Policy - { - Enabled = true, - }); + sutProvider.GetDependency().RunAsync( + Arg.Any(), Arg.Any()).Returns(policy); await sutProvider.Sut.SaveAsync(ssoConfig, organization); diff --git a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessMailTests.cs b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessMailTests.cs new file mode 100644 index 0000000000..8cb6c2c2fe --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessMailTests.cs @@ -0,0 +1,153 @@ +using Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail; +using Bit.Core.Models.Mail; +using Bit.Core.Platform.Mail.Delivery; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Auth.UserFeatures.EmergencyAccess; + +[SutProviderCustomize] +public class EmergencyAccessMailTests +{ + // Constant values for all Emergency Access emails + private const string _emergencyAccessHelpUrl = "https://bitwarden.com/help/emergency-access/"; + private const string _emergencyAccessMailSubject = "Emergency contacts removed"; + + /// + /// Documents how to construct and send the emergency access removal email. + /// 1. Inject IMailer into their command/service + /// 2. Construct EmergencyAccessRemoveGranteesMail as shown below + /// 3. Call mailer.SendEmail(mail) + /// + [Theory, BitAutoData] + public async Task SendEmergencyAccessRemoveGranteesEmail_SingleGrantee_Success( + string grantorEmail, + string granteeName) + { + // Arrange + var logger = Substitute.For>(); + var globalSettings = new GlobalSettings { SelfHosted = false }; + var deliveryService = Substitute.For(); + var mailer = new Mailer( + new HandlebarMailRenderer(logger, globalSettings), + deliveryService); + + var mail = new EmergencyAccessRemoveGranteesMail + { + ToEmails = [grantorEmail], + View = new EmergencyAccessRemoveGranteesMailView + { + RemovedGranteeNames = [granteeName] + } + }; + + MailMessage sentMessage = null; + await deliveryService.SendEmailAsync(Arg.Do(message => + sentMessage = message + )); + + // Act + await mailer.SendEmail(mail); + + // Assert + Assert.NotNull(sentMessage); + Assert.Contains(grantorEmail, sentMessage.ToEmails); + + // Verify the content contains the grantee name + Assert.Contains(granteeName, sentMessage.TextContent); + Assert.Contains(granteeName, sentMessage.HtmlContent); + } + + /// + /// Documents handling multiple removed grantees in a single email. + /// + [Theory, BitAutoData] + public async Task SendEmergencyAccessRemoveGranteesEmail_MultipleGrantees_RendersAllNames( + string grantorEmail) + { + // Arrange + var logger = Substitute.For>(); + var globalSettings = new GlobalSettings { SelfHosted = false }; + var deliveryService = Substitute.For(); + var mailer = new Mailer( + new HandlebarMailRenderer(logger, globalSettings), + deliveryService); + + var granteeNames = new[] { "Alice", "Bob", "Carol" }; + + var mail = new EmergencyAccessRemoveGranteesMail + { + ToEmails = [grantorEmail], + View = new EmergencyAccessRemoveGranteesMailView + { + RemovedGranteeNames = granteeNames + } + }; + + MailMessage sentMessage = null; + await deliveryService.SendEmailAsync(Arg.Do(message => + sentMessage = message + )); + + // Act + await mailer.SendEmail(mail); + + // Assert - All grantee names should appear in the email + Assert.NotNull(sentMessage); + foreach (var granteeName in granteeNames) + { + Assert.Contains(granteeName, sentMessage.TextContent); + Assert.Contains(granteeName, sentMessage.HtmlContent); + } + } + + /// + /// Validates the required GranteeNames for the email view model. + /// + [Theory, BitAutoData] + public void EmergencyAccessRemoveGranteesMailView_GranteeNames_AreRequired( + string grantorEmail) + { + // Arrange - Shows the minimum required to construct the email + var mail = new EmergencyAccessRemoveGranteesMail + { + ToEmails = [grantorEmail], // Required: who to send to + View = new EmergencyAccessRemoveGranteesMailView + { + // Required: at least one removed grantee name + RemovedGranteeNames = ["Example Grantee"] + } + }; + + // Assert + Assert.NotNull(mail); + Assert.NotNull(mail.View); + Assert.NotEmpty(mail.View.RemovedGranteeNames); + } + + /// + /// Ensure consistency with help pages link and email subject. + /// + /// + /// + [Theory, BitAutoData] + public void EmergencyAccessRemoveGranteesMailView_SubjectAndHelpLink_MatchesExpectedValues(string grantorEmail, string granteeName) + { + // Arrange + var mail = new EmergencyAccessRemoveGranteesMail + { + ToEmails = [grantorEmail], + View = new EmergencyAccessRemoveGranteesMailView { RemovedGranteeNames = [granteeName] } + }; + + // Assert + Assert.NotNull(mail); + Assert.NotNull(mail.View); + Assert.Equal(_emergencyAccessMailSubject, mail.Subject); + Assert.Equal(_emergencyAccessHelpUrl, mail.View.EmergencyAccessHelpPageUrl); + } +} diff --git a/test/Core.Test/Auth/Services/EmergencyAccessServiceTests.cs b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs similarity index 92% rename from test/Core.Test/Auth/Services/EmergencyAccessServiceTests.cs rename to test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs index 006515aafd..83585e6667 100644 --- a/test/Core.Test/Auth/Services/EmergencyAccessServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs @@ -1,11 +1,10 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; -using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -17,7 +16,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -namespace Bit.Core.Test.Auth.Services; +namespace Bit.Core.Test.Auth.UserFeatures.EmergencyAccess; [SutProviderCustomize] public class EmergencyAccessServiceTests @@ -68,13 +67,13 @@ public class EmergencyAccessServiceTests Assert.Equal(EmergencyAccessStatusType.Invited, result.Status); await sutProvider.GetDependency() .Received(1) - .CreateAsync(Arg.Any()); + .CreateAsync(Arg.Any()); sutProvider.GetDependency>() .Received(1) .Protect(Arg.Any()); await sutProvider.GetDependency() .Received(1) - .SendEmergencyAccessInviteEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + .SendEmergencyAccessInviteEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Theory, BitAutoData] @@ -98,7 +97,7 @@ public class EmergencyAccessServiceTests User invitingUser, Guid emergencyAccessId) { - EmergencyAccess emergencyAccess = null; + Core.Auth.Entities.EmergencyAccess emergencyAccess = null; sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) @@ -119,7 +118,7 @@ public class EmergencyAccessServiceTests User invitingUser, Guid emergencyAccessId) { - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = EmergencyAccessStatusType.Invited, GrantorId = Guid.NewGuid(), @@ -148,7 +147,7 @@ public class EmergencyAccessServiceTests User invitingUser, Guid emergencyAccessId) { - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = statusType, GrantorId = invitingUser.Id, @@ -172,7 +171,7 @@ public class EmergencyAccessServiceTests User invitingUser, Guid emergencyAccessId) { - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = EmergencyAccessStatusType.Invited, GrantorId = invitingUser.Id, @@ -194,7 +193,7 @@ public class EmergencyAccessServiceTests public async Task AcceptUserAsync_EmergencyAccessNull_ThrowsBadRequest( SutProvider sutProvider, User acceptingUser, string token) { - EmergencyAccess emergencyAccess = null; + Core.Auth.Entities.EmergencyAccess emergencyAccess = null; sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) .Returns(emergencyAccess); @@ -209,7 +208,7 @@ public class EmergencyAccessServiceTests public async Task AcceptUserAsync_CannotUnprotectToken_ThrowsBadRequest( SutProvider sutProvider, User acceptingUser, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string token) { sutProvider.GetDependency() @@ -230,8 +229,8 @@ public class EmergencyAccessServiceTests public async Task AcceptUserAsync_TokenDataInvalid_ThrowsBadRequest( SutProvider sutProvider, User acceptingUser, - EmergencyAccess emergencyAccess, - EmergencyAccess wrongEmergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess wrongEmergencyAccess, string token) { sutProvider.GetDependency() @@ -257,7 +256,7 @@ public class EmergencyAccessServiceTests public async Task AcceptUserAsync_AcceptedStatus_ThrowsBadRequest( SutProvider sutProvider, User acceptingUser, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string token) { emergencyAccess.Status = EmergencyAccessStatusType.Accepted; @@ -284,7 +283,7 @@ public class EmergencyAccessServiceTests public async Task AcceptUserAsync_NotInvitedStatus_ThrowsBadRequest( SutProvider sutProvider, User acceptingUser, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string token) { emergencyAccess.Status = EmergencyAccessStatusType.Confirmed; @@ -311,7 +310,7 @@ public class EmergencyAccessServiceTests public async Task AcceptUserAsync_EmergencyAccessEmailDoesNotMatch_ThrowsBadRequest( SutProvider sutProvider, User acceptingUser, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string token) { emergencyAccess.Status = EmergencyAccessStatusType.Invited; @@ -339,7 +338,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User acceptingUser, User invitingUser, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string token) { emergencyAccess.Status = EmergencyAccessStatusType.Invited; @@ -364,7 +363,7 @@ public class EmergencyAccessServiceTests await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Accepted)); + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Accepted)); await sutProvider.GetDependency() .Received(1) @@ -375,11 +374,11 @@ public class EmergencyAccessServiceTests public async Task DeleteAsync_EmergencyAccessNull_ThrowsBadRequest( SutProvider sutProvider, User invitingUser, - EmergencyAccess emergencyAccess) + Core.Auth.Entities.EmergencyAccess emergencyAccess) { sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.DeleteAsync(emergencyAccess.Id, invitingUser.Id)); @@ -391,7 +390,7 @@ public class EmergencyAccessServiceTests public async Task DeleteAsync_EmergencyAccessGrantorIdNotEqual_ThrowsBadRequest( SutProvider sutProvider, User invitingUser, - EmergencyAccess emergencyAccess) + Core.Auth.Entities.EmergencyAccess emergencyAccess) { emergencyAccess.GrantorId = Guid.NewGuid(); sutProvider.GetDependency() @@ -408,7 +407,7 @@ public class EmergencyAccessServiceTests public async Task DeleteAsync_EmergencyAccessGranteeIdNotEqual_ThrowsBadRequest( SutProvider sutProvider, User invitingUser, - EmergencyAccess emergencyAccess) + Core.Auth.Entities.EmergencyAccess emergencyAccess) { emergencyAccess.GranteeId = Guid.NewGuid(); sutProvider.GetDependency() @@ -425,7 +424,7 @@ public class EmergencyAccessServiceTests public async Task DeleteAsync_EmergencyAccessIsDeleted_Success( SutProvider sutProvider, User user, - EmergencyAccess emergencyAccess) + Core.Auth.Entities.EmergencyAccess emergencyAccess) { emergencyAccess.GranteeId = user.Id; emergencyAccess.GrantorId = user.Id; @@ -443,7 +442,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ConfirmUserAsync_EmergencyAccessNull_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string key, User grantorUser) { @@ -451,7 +450,7 @@ public class EmergencyAccessServiceTests emergencyAccess.Status = EmergencyAccessStatusType.RecoveryInitiated; sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ConfirmUserAsync(emergencyAccess.Id, key, grantorUser.Id)); @@ -463,7 +462,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ConfirmUserAsync_EmergencyAccessStatusIsNotAccepted_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string key, User grantorUser) { @@ -484,7 +483,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ConfirmUserAsync_EmergencyAccessGrantorIdNotEqualToConfirmingUserId_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string key, User grantorUser) { @@ -505,7 +504,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User confirmingUser, string key) { confirmingUser.UsesKeyConnector = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = EmergencyAccessStatusType.Accepted, GrantorId = confirmingUser.Id, @@ -530,7 +529,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ConfirmUserAsync_ConfirmsAndReplacesEmergencyAccess_Success( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, string key, User grantorUser, User granteeUser) @@ -553,7 +552,7 @@ public class EmergencyAccessServiceTests await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Confirmed)); + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Confirmed)); await sutProvider.GetDependency() .Received(1) @@ -564,7 +563,7 @@ public class EmergencyAccessServiceTests public async Task SaveAsync_PremiumCannotUpdate_ThrowsBadRequest( SutProvider sutProvider, User savingUser) { - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Type = EmergencyAccessType.Takeover, GrantorId = savingUser.Id, @@ -586,7 +585,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User savingUser) { savingUser.Premium = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Type = EmergencyAccessType.Takeover, GrantorId = new Guid(), @@ -611,7 +610,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User grantorUser) { grantorUser.UsesKeyConnector = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Type = EmergencyAccessType.Takeover, GrantorId = grantorUser.Id, @@ -633,7 +632,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User grantorUser) { grantorUser.UsesKeyConnector = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Type = EmergencyAccessType.View, GrantorId = grantorUser.Id, @@ -655,7 +654,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User grantorUser) { grantorUser.UsesKeyConnector = false; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Type = EmergencyAccessType.Takeover, GrantorId = grantorUser.Id, @@ -678,7 +677,7 @@ public class EmergencyAccessServiceTests { sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.InitiateAsync(new Guid(), initiatingUser)); @@ -692,7 +691,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task InitiateAsync_EmergencyAccessGranteeIdNotEqual_ThrowBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User initiatingUser) { emergencyAccess.GranteeId = new Guid(); @@ -712,7 +711,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task InitiateAsync_EmergencyAccessStatusIsNotConfirmed_ThrowBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User initiatingUser) { emergencyAccess.GranteeId = initiatingUser.Id; @@ -735,7 +734,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User initiatingUser, User grantor) { grantor.UsesKeyConnector = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = EmergencyAccessStatusType.Confirmed, GranteeId = initiatingUser.Id, @@ -764,7 +763,7 @@ public class EmergencyAccessServiceTests SutProvider sutProvider, User initiatingUser, User grantor) { grantor.UsesKeyConnector = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = EmergencyAccessStatusType.Confirmed, GranteeId = initiatingUser.Id, @@ -783,14 +782,14 @@ public class EmergencyAccessServiceTests await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.RecoveryInitiated)); + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.RecoveryInitiated)); } [Theory, BitAutoData] public async Task InitiateAsync_RequestIsCorrect_Success( SutProvider sutProvider, User initiatingUser, User grantor) { - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { Status = EmergencyAccessStatusType.Confirmed, GranteeId = initiatingUser.Id, @@ -809,7 +808,7 @@ public class EmergencyAccessServiceTests await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.RecoveryInitiated)); + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.RecoveryInitiated)); } [Theory, BitAutoData] @@ -818,7 +817,7 @@ public class EmergencyAccessServiceTests { sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ApproveAsync(new Guid(), null)); @@ -829,7 +828,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ApproveAsync_EmergencyAccessGrantorIdNotEquatToApproving_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User grantorUser) { emergencyAccess.Status = EmergencyAccessStatusType.RecoveryInitiated; @@ -851,7 +850,7 @@ public class EmergencyAccessServiceTests public async Task ApproveAsync_EmergencyAccessStatusNotRecoveryInitiated_ThrowsBadRequest( EmergencyAccessStatusType statusType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User grantorUser) { emergencyAccess.GrantorId = grantorUser.Id; @@ -869,7 +868,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ApproveAsync_Success( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User grantorUser, User granteeUser) { @@ -885,20 +884,20 @@ public class EmergencyAccessServiceTests await sutProvider.Sut.ApproveAsync(emergencyAccess.Id, grantorUser); await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.RecoveryApproved)); + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.RecoveryApproved)); } [Theory, BitAutoData] public async Task RejectAsync_EmergencyAccessIdNull_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User GrantorUser) { emergencyAccess.GrantorId = GrantorUser.Id; emergencyAccess.Status = EmergencyAccessStatusType.Accepted; sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RejectAsync(emergencyAccess.Id, GrantorUser)); @@ -909,7 +908,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task RejectAsync_EmergencyAccessGrantorIdNotEqualToRequestUser_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User GrantorUser) { emergencyAccess.Status = EmergencyAccessStatusType.Accepted; @@ -930,7 +929,7 @@ public class EmergencyAccessServiceTests public async Task RejectAsync_EmergencyAccessStatusNotValid_ThrowsBadRequest( EmergencyAccessStatusType statusType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User GrantorUser) { emergencyAccess.GrantorId = GrantorUser.Id; @@ -951,7 +950,7 @@ public class EmergencyAccessServiceTests public async Task RejectAsync_Success( EmergencyAccessStatusType statusType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User GrantorUser, User GranteeUser) { @@ -968,7 +967,7 @@ public class EmergencyAccessServiceTests await sutProvider.GetDependency() .Received(1) - .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Confirmed)); + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Confirmed)); } [Theory, BitAutoData] @@ -977,7 +976,7 @@ public class EmergencyAccessServiceTests { sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.GetPoliciesAsync(default, default)); @@ -992,7 +991,7 @@ public class EmergencyAccessServiceTests public async Task GetPoliciesAsync_RequestNotValidStatusType_ThrowsBadRequest( EmergencyAccessStatusType statusType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1010,7 +1009,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task GetPoliciesAsync_RequestNotValidType_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1032,7 +1031,7 @@ public class EmergencyAccessServiceTests public async Task GetPoliciesAsync_OrganizationUserTypeNotOwner_ReturnsNull( OrganizationUserType userType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, User grantorUser, OrganizationUser grantorOrganizationUser) @@ -1062,7 +1061,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task GetPoliciesAsync_OrganizationUserEmpty_ReturnsNull( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, User grantorUser) { @@ -1090,7 +1089,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task GetPoliciesAsync_ReturnsNotNull( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, User grantorUser, OrganizationUser grantorOrganizationUser) @@ -1127,7 +1126,7 @@ public class EmergencyAccessServiceTests { sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.TakeoverAsync(default, default)); @@ -1138,7 +1137,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task TakeoverAsync_RequestNotValid_GranteeNotEqualToRequestingUser_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved; @@ -1161,7 +1160,7 @@ public class EmergencyAccessServiceTests public async Task TakeoverAsync_RequestNotValid_StatusType_ThrowsBadRequest( EmergencyAccessStatusType statusType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1180,7 +1179,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task TakeoverAsync_RequestNotValid_TypeIsView_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1203,7 +1202,7 @@ public class EmergencyAccessServiceTests User grantor) { grantor.UsesKeyConnector = true; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { GrantorId = grantor.Id, GranteeId = granteeUser.Id, @@ -1232,7 +1231,7 @@ public class EmergencyAccessServiceTests User grantor) { grantor.UsesKeyConnector = false; - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { GrantorId = grantor.Id, GranteeId = granteeUser.Id, @@ -1260,7 +1259,7 @@ public class EmergencyAccessServiceTests { sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) - .Returns((EmergencyAccess)null); + .Returns((Core.Auth.Entities.EmergencyAccess)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.PasswordAsync(default, default, default, default)); @@ -1271,7 +1270,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task PasswordAsync_RequestNotValid_GranteeNotEqualToRequestingUser_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.Status = EmergencyAccessStatusType.RecoveryApproved; @@ -1294,7 +1293,7 @@ public class EmergencyAccessServiceTests public async Task PasswordAsync_RequestNotValid_StatusType_ThrowsBadRequest( EmergencyAccessStatusType statusType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1313,7 +1312,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task PasswordAsync_RequestNotValid_TypeIsView_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1332,7 +1331,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task PasswordAsync_NonOrgUser_Success( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, User grantorUser, string key, @@ -1367,7 +1366,7 @@ public class EmergencyAccessServiceTests public async Task PasswordAsync_OrgUser_NotOrganizationOwner_RemovedFromOrganization_Success( OrganizationUserType userType, SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, User grantorUser, OrganizationUser organizationUser, @@ -1408,7 +1407,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task PasswordAsync_OrgUser_IsOrganizationOwner_NotRemovedFromOrganization_Success( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser, User grantorUser, OrganizationUser organizationUser, @@ -1459,7 +1458,7 @@ public class EmergencyAccessServiceTests Enabled = true } }); - var emergencyAccess = new EmergencyAccess + var emergencyAccess = new Core.Auth.Entities.EmergencyAccess { GrantorId = grantor.Id, GranteeId = requestingUser.Id, @@ -1484,7 +1483,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task ViewAsync_EmergencyAccessTypeNotView_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; @@ -1500,7 +1499,7 @@ public class EmergencyAccessServiceTests [Theory, BitAutoData] public async Task GetAttachmentDownloadAsync_EmergencyAccessTypeNotView_ThrowsBadRequest( SutProvider sutProvider, - EmergencyAccess emergencyAccess, + Core.Auth.Entities.EmergencyAccess emergencyAccess, User granteeUser) { emergencyAccess.GranteeId = granteeUser.Id; diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index ae669398c5..29193bacbc 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -1,8 +1,8 @@ using System.Text; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Auth.Entities; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; @@ -14,6 +14,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterpri using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Tokens; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; @@ -23,6 +24,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.WebUtilities; using NSubstitute; using Xunit; +using EmergencyAccessEntity = Bit.Core.Auth.Entities.EmergencyAccess; namespace Bit.Core.Test.Auth.UserFeatures.Registration; @@ -241,7 +243,8 @@ public class RegisterUserCommandTests [BitAutoData(true, "sampleInitiationPath")] [BitAutoData(true, "Secrets Manager trial")] public async Task RegisterUserViaOrganizationInviteToken_ComplexHappyPath_Succeeds(bool addUserReferenceData, string initiationPath, - SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, Policy twoFactorPolicy) + SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, + [Policy(PolicyType.TwoFactorAuthentication, true)] PolicyStatus policy) { // Arrange sutProvider.GetDependency() @@ -267,10 +270,9 @@ public class RegisterUserCommandTests .GetByIdAsync(orgUserId) .Returns(orgUser); - twoFactorPolicy.Enabled = true; - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication) - .Returns(twoFactorPolicy); + sutProvider.GetDependency() + .RunAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication) + .Returns(policy); sutProvider.GetDependency() .CreateUserAsync(user, masterPasswordHash) @@ -286,9 +288,9 @@ public class RegisterUserCommandTests .Received(1) .GetByIdAsync(orgUserId); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .GetByOrganizationIdTypeAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication); + .RunAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication); sutProvider.GetDependency() .Received(1) @@ -431,7 +433,8 @@ public class RegisterUserCommandTests [Theory] [BitAutoData] public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromDifferentOrg_ThrowsBadRequestException( - SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId) + SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) { // Arrange user.Email = "user@blocked-domain.com"; @@ -463,6 +466,10 @@ public class RegisterUserCommandTests .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com", orgUser.OrganizationId) .Returns(true); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns(policy); + // Act & Assert var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId)); @@ -472,7 +479,8 @@ public class RegisterUserCommandTests [Theory] [BitAutoData] public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromSameOrg_Succeeds( - SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId) + SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) { // Arrange user.Email = "user@company-domain.com"; @@ -509,6 +517,10 @@ public class RegisterUserCommandTests .CreateUserAsync(user, masterPasswordHash) .Returns(IdentityResult.Success); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns(policy); + // Act var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId); @@ -726,7 +738,7 @@ public class RegisterUserCommandTests [BitAutoData] public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_Succeeds( SutProvider sutProvider, User user, string masterPasswordHash, - EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -767,7 +779,7 @@ public class RegisterUserCommandTests [Theory] [BitAutoData] public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_InvalidToken_ThrowsBadRequestException(SutProvider sutProvider, User user, - string masterPasswordHash, EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + string masterPasswordHash, EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -1112,7 +1124,7 @@ public class RegisterUserCommandTests [BitAutoData] public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_BlockedDomain_ThrowsBadRequestException( SutProvider sutProvider, User user, string masterPasswordHash, - EmergencyAccess emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange user.Email = "user@blocked-domain.com"; @@ -1245,6 +1257,7 @@ public class RegisterUserCommandTests OrganizationUser orgUser, string orgInviteToken, string masterPasswordHash, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy, SutProvider sutProvider) { // Arrange @@ -1259,9 +1272,9 @@ public class RegisterUserCommandTests .GetByIdAsync(orgUser.Id) .Returns(orgUser); - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) - .Returns((Policy)null); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns(policy); sutProvider.GetDependency() .GetByIdAsync(orgUser.OrganizationId) @@ -1331,6 +1344,7 @@ public class RegisterUserCommandTests OrganizationUser orgUser, string masterPasswordHash, string orgInviteToken, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy, SutProvider sutProvider) { // Arrange @@ -1346,9 +1360,9 @@ public class RegisterUserCommandTests .GetByIdAsync(orgUser.Id) .Returns(orgUser); - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) - .Returns((Policy)null); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns(policy); sutProvider.GetDependency() .GetByIdAsync(orgUser.OrganizationId) diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandTests.cs index 1605d279a2..4040650954 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandTests.cs @@ -1,8 +1,10 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -21,106 +23,154 @@ public class SetInitialMasterPasswordCommandTests [Theory] [BitAutoData] public async Task SetInitialMasterPassword_Success(SutProvider sutProvider, - User user, string masterPassword, string key, string orgIdentifier, - Organization org, OrganizationUser orgUser) + User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, + Organization org, OrganizationUser orgUser, string serverSideHash, string masterPasswordHint) { // Arrange - user.MasterPassword = null; - - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) - .Returns(IdentityResult.Success); + user.Key = null; + var model = CreateValidModel(user, accountKeys, kdfSettings, org.Identifier, masterPasswordHint); sutProvider.GetDependency() - .GetByIdentifierAsync(orgIdentifier) + .GetByIdentifierAsync(org.Identifier) .Returns(org); sutProvider.GetDependency() .GetByOrganizationAsync(org.Id, user.Id) .Returns(orgUser); - // Act - var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + sutProvider.GetDependency>() + .HashPassword(user, model.MasterPasswordAuthentication.MasterPasswordAuthenticationHash) + .Returns(serverSideHash); - // Assert - Assert.Equal(IdentityResult.Success, result); - } - - [Theory] - [BitAutoData] - public async Task SetInitialMasterPassword_UserIsNull_ThrowsArgumentNullException(SutProvider sutProvider, string masterPassword, string key, string orgIdentifier) - { - // Act & Assert - await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(null, masterPassword, key, orgIdentifier)); - } - - [Theory] - [BitAutoData] - public async Task SetInitialMasterPassword_AlreadyHasPassword_ReturnsFalse(SutProvider sutProvider, User user, string masterPassword, string key, string orgIdentifier) - { - // Arrange - user.MasterPassword = "ExistingPassword"; + // Mock SetMasterPassword to return a specific UpdateUserData delegate + UpdateUserData mockUpdateUserData = (connection, transaction) => Task.CompletedTask; + sutProvider.GetDependency() + .SetMasterPassword(user.Id, model.MasterPasswordUnlock, serverSideHash, model.MasterPasswordHint) + .Returns(mockUpdateUserData); // Act - var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model); // Assert - Assert.False(result.Succeeded); + await sutProvider.GetDependency().Received(1) + .SetV2AccountCryptographicStateAsync( + user.Id, + model.AccountKeys, + Arg.Do>(actions => + { + var actionsList = actions.ToList(); + Assert.Single(actionsList); + Assert.Same(mockUpdateUserData, actionsList[0]); + })); + + await sutProvider.GetDependency().Received(1) + .LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + + await sutProvider.GetDependency().Received(1) + .AcceptOrgUserAsync(orgUser, user, sutProvider.GetDependency()); } [Theory] [BitAutoData] - public async Task SetInitialMasterPassword_NullOrgSsoIdentifier_ThrowsBadRequestException( - SutProvider sutProvider, User user, string masterPassword, string key) + public async Task SetInitialMasterPassword_UserAlreadyHasPassword_ThrowsBadRequestException( + SutProvider sutProvider, + User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) { // Arrange - user.MasterPassword = null; - string orgSsoIdentifier = null; - - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) - .Returns(IdentityResult.Success); + user.Key = "existing-key"; + var model = CreateValidModel(user, accountKeys, kdfSettings, orgSsoIdentifier, masterPasswordHint); // Act & Assert var exception = await Assert.ThrowsAsync( - async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgSsoIdentifier)); - Assert.Equal("Organization SSO Identifier required.", exception.Message); + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model)); + Assert.Equal("User already has a master password set.", exception.Message); } - [Theory] [BitAutoData] - public async Task SetInitialMasterPassword_InvalidOrganization_Throws(SutProvider sutProvider, User user, string masterPassword, string key, string orgIdentifier) + public async Task SetInitialMasterPassword_AccountKeysNull_ThrowsBadRequestException( + SutProvider sutProvider, + User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) { // Arrange - user.MasterPassword = null; + user.Key = null; + var model = CreateValidModel(user, null, kdfSettings, orgSsoIdentifier, masterPasswordHint); - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) - .Returns(IdentityResult.Success); + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model)); + Assert.Equal("Account keys are required.", exception.Message); + } + + [Theory] + [BitAutoData("wrong-salt", null)] + [BitAutoData([null, "wrong-salt"])] + [BitAutoData("wrong-salt", "different-wrong-salt")] + public async Task SetInitialMasterPassword_InvalidSalt_ThrowsBadRequestException( + string? authSaltOverride, string? unlockSaltOverride, + SutProvider sutProvider, + User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) + { + // Arrange + user.Key = null; + var correctSalt = user.GetMasterPasswordSalt(); + var model = new SetInitialMasterPasswordDataModel + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Salt = authSaltOverride ?? correctSalt, + MasterPasswordAuthenticationHash = "hash", + Kdf = kdfSettings + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Salt = unlockSaltOverride ?? correctSalt, + MasterKeyWrappedUserKey = "wrapped-key", + Kdf = kdfSettings + }, + AccountKeys = accountKeys, + OrgSsoIdentifier = orgSsoIdentifier, + MasterPasswordHint = masterPasswordHint + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model)); + Assert.Equal("Invalid master password salt.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_InvalidOrgSsoIdentifier_ThrowsBadRequestException( + SutProvider sutProvider, + User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) + { + // Arrange + user.Key = null; + var model = CreateValidModel(user, accountKeys, kdfSettings, orgSsoIdentifier, masterPasswordHint); sutProvider.GetDependency() - .GetByIdentifierAsync(orgIdentifier) + .GetByIdentifierAsync(orgSsoIdentifier) .ReturnsNull(); // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier)); - Assert.Equal("Organization invalid.", exception.Message); + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model)); + Assert.Equal("Organization SSO identifier is invalid.", exception.Message); } [Theory] [BitAutoData] - public async Task SetInitialMasterPassword_UserNotFoundInOrganization_Throws(SutProvider sutProvider, User user, string masterPassword, string key, Organization org) + public async Task SetInitialMasterPassword_UserNotFoundInOrganization_ThrowsBadRequestException( + SutProvider sutProvider, + User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, Organization org, string masterPasswordHint) { // Arrange - user.MasterPassword = null; - - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) - .Returns(IdentityResult.Success); + user.Key = null; + var model = CreateValidModel(user, accountKeys, kdfSettings, org.Identifier, masterPasswordHint); sutProvider.GetDependency() - .GetByIdentifierAsync(Arg.Any()) + .GetByIdentifierAsync(org.Identifier) .Returns(org); sutProvider.GetDependency() @@ -128,67 +178,33 @@ public class SetInitialMasterPasswordCommandTests .ReturnsNull(); // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, org.Identifier)); + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model)); Assert.Equal("User not found within organization.", exception.Message); } - [Theory] - [BitAutoData] - public async Task SetInitialMasterPassword_ConfirmedOrgUser_DoesNotCallAcceptOrgUser(SutProvider sutProvider, - User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser) + private static SetInitialMasterPasswordDataModel CreateValidModel( + User user, UserAccountKeysData? accountKeys, KdfSettings kdfSettings, + string orgSsoIdentifier, string? masterPasswordHint) { - // Arrange - user.MasterPassword = null; - - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) - .Returns(IdentityResult.Success); - - sutProvider.GetDependency() - .GetByIdentifierAsync(orgIdentifier) - .Returns(org); - - orgUser.Status = OrganizationUserStatusType.Confirmed; - sutProvider.GetDependency() - .GetByOrganizationAsync(org.Id, user.Id) - .Returns(orgUser); - - - // Act - var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); - - // Assert - Assert.Equal(IdentityResult.Success, result); - await sutProvider.GetDependency().DidNotReceive().AcceptOrgUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + var salt = user.GetMasterPasswordSalt(); + return new SetInitialMasterPasswordDataModel + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Salt = salt, + MasterPasswordAuthenticationHash = "hash", + Kdf = kdfSettings + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Salt = salt, + MasterKeyWrappedUserKey = "wrapped-key", + Kdf = kdfSettings + }, + AccountKeys = accountKeys, + OrgSsoIdentifier = orgSsoIdentifier, + MasterPasswordHint = masterPasswordHint + }; } - - [Theory] - [BitAutoData] - public async Task SetInitialMasterPassword_InvitedOrgUser_CallsAcceptOrgUser(SutProvider sutProvider, - User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser) - { - // Arrange - user.MasterPassword = null; - - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) - .Returns(IdentityResult.Success); - - sutProvider.GetDependency() - .GetByIdentifierAsync(orgIdentifier) - .Returns(org); - - orgUser.Status = OrganizationUserStatusType.Invited; - sutProvider.GetDependency() - .GetByOrganizationAsync(org.Id, user.Id) - .Returns(orgUser); - - // Act - var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); - - // Assert - Assert.Equal(IdentityResult.Success, result); - await sutProvider.GetDependency().Received(1).AcceptOrgUserAsync(orgUser, user, sutProvider.GetDependency()); - } - } diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1Tests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1Tests.cs new file mode 100644 index 0000000000..d87b273026 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1Tests.cs @@ -0,0 +1,194 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; + +[SutProviderCustomize] +public class SetInitialMasterPasswordCommandV1Tests +{ + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_Success(SutProvider sutProvider, + User user, string masterPassword, string key, string orgIdentifier, + Organization org, OrganizationUser orgUser) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgIdentifier) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + // Act + var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + + // Assert + Assert.Equal(IdentityResult.Success, result); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_UserIsNull_ThrowsArgumentNullException(SutProvider sutProvider, string masterPassword, string key, string orgIdentifier) + { + // Act & Assert + await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(null, masterPassword, key, orgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_AlreadyHasPassword_ReturnsFalse(SutProvider sutProvider, User user, string masterPassword, string key, string orgIdentifier) + { + // Arrange + user.MasterPassword = "ExistingPassword"; + + // Act + var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + + // Assert + Assert.False(result.Succeeded); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_NullOrgSsoIdentifier_ThrowsBadRequestException( + SutProvider sutProvider, User user, string masterPassword, string key) + { + // Arrange + user.MasterPassword = null; + string orgSsoIdentifier = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgSsoIdentifier)); + Assert.Equal("Organization SSO Identifier required.", exception.Message); + } + + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_InvalidOrganization_Throws(SutProvider sutProvider, User user, string masterPassword, string key, string orgIdentifier) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgIdentifier) + .ReturnsNull(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier)); + Assert.Equal("Organization invalid.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_UserNotFoundInOrganization_Throws(SutProvider sutProvider, User user, string masterPassword, string key, Organization org) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(Arg.Any()) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .ReturnsNull(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, org.Identifier)); + Assert.Equal("User not found within organization.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_ConfirmedOrgUser_DoesNotCallAcceptOrgUser(SutProvider sutProvider, + User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgIdentifier) + .Returns(org); + + orgUser.Status = OrganizationUserStatusType.Confirmed; + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + + // Act + var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + + // Assert + Assert.Equal(IdentityResult.Success, result); + await sutProvider.GetDependency().DidNotReceive().AcceptOrgUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_InvitedOrgUser_CallsAcceptOrgUser(SutProvider sutProvider, + User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgIdentifier) + .Returns(org); + + orgUser.Status = OrganizationUserStatusType.Invited; + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + // Act + var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + + // Assert + Assert.Equal(IdentityResult.Success, result); + await sutProvider.GetDependency().Received(1).AcceptOrgUserAsync(orgUser, user, sutProvider.GetDependency()); + } + +} diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommandTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommandTests.cs new file mode 100644 index 0000000000..5a144b7042 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommandTests.cs @@ -0,0 +1,223 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; + +[SutProviderCustomize] +public class TdeSetPasswordCommandTests +{ + [Theory] + [BitAutoData] + public async Task OnboardMasterPassword_Success(SutProvider sutProvider, + User user, KdfSettings kdfSettings, + Organization org, OrganizationUser orgUser, string serverSideHash, string masterPasswordHint) + { + // Arrange + user.Key = null; + user.PublicKey = "public-key"; + user.PrivateKey = "private-key"; + var model = CreateValidModel(user, kdfSettings, org.Identifier, masterPasswordHint); + + sutProvider.GetDependency() + .GetByIdentifierAsync(org.Identifier) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + sutProvider.GetDependency>() + .HashPassword(user, model.MasterPasswordAuthentication.MasterPasswordAuthenticationHash) + .Returns(serverSideHash); + + // Mock SetMasterPassword to return a specific UpdateUserData delegate + UpdateUserData mockUpdateUserData = (connection, transaction) => Task.CompletedTask; + sutProvider.GetDependency() + .SetMasterPassword(user.Id, model.MasterPasswordUnlock, serverSideHash, model.MasterPasswordHint) + .Returns(mockUpdateUserData); + + // Act + await sutProvider.Sut.SetMasterPasswordAsync(user, model); + + // Assert + await sutProvider.GetDependency().Received(1) + .UpdateUserDataAsync(Arg.Do>(actions => + { + var actionsList = actions.ToList(); + Assert.Single(actionsList); + Assert.Same(mockUpdateUserData, actionsList[0]); + })); + + await sutProvider.GetDependency().Received(1) + .LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + } + + [Theory] + [BitAutoData] + public async Task OnboardMasterPassword_UserAlreadyHasPassword_ThrowsBadRequestException( + SutProvider sutProvider, + User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) + { + // Arrange + user.Key = "existing-key"; + var model = CreateValidModel(user, kdfSettings, orgSsoIdentifier, masterPasswordHint); + + // Act & Assert + var exception = + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.SetMasterPasswordAsync(user, model)); + Assert.Equal("User already has a master password set.", exception.Message); + } + + [Theory] + [BitAutoData([null, "private-key"])] + [BitAutoData("public-key", null)] + [BitAutoData([null, null])] + public async Task OnboardMasterPassword_MissingAccountKeys_ThrowsBadRequestException( + string? publicKey, string? privateKey, + SutProvider sutProvider, + User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) + { + // Arrange + user.Key = null; + user.PublicKey = publicKey; + user.PrivateKey = privateKey; + var model = CreateValidModel(user, kdfSettings, orgSsoIdentifier, masterPasswordHint); + + // Act & Assert + var exception = + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.SetMasterPasswordAsync(user, model)); + Assert.Equal("TDE user account keys must be set before setting initial master password.", exception.Message); + } + + [Theory] + [BitAutoData("wrong-salt", null)] + [BitAutoData([null, "wrong-salt"])] + [BitAutoData("wrong-salt", "different-wrong-salt")] + public async Task OnboardMasterPassword_InvalidSalt_ThrowsBadRequestException( + string? authSaltOverride, string? unlockSaltOverride, + SutProvider sutProvider, + User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) + { + // Arrange + user.Key = null; + user.PublicKey = "public-key"; + user.PrivateKey = "private-key"; + var correctSalt = user.GetMasterPasswordSalt(); + var model = new SetInitialMasterPasswordDataModel + { + MasterPasswordAuthentication = + new MasterPasswordAuthenticationData + { + Salt = authSaltOverride ?? correctSalt, + MasterPasswordAuthenticationHash = "hash", + Kdf = kdfSettings + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Salt = unlockSaltOverride ?? correctSalt, + MasterKeyWrappedUserKey = "wrapped-key", + Kdf = kdfSettings + }, + AccountKeys = null, + OrgSsoIdentifier = orgSsoIdentifier, + MasterPasswordHint = masterPasswordHint + }; + + // Act & Assert + var exception = + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.SetMasterPasswordAsync(user, model)); + Assert.Equal("Invalid master password salt.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task OnboardMasterPassword_InvalidOrgSsoIdentifier_ThrowsBadRequestException( + SutProvider sutProvider, + User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) + { + // Arrange + user.Key = null; + user.PublicKey = "public-key"; + user.PrivateKey = "private-key"; + var model = CreateValidModel(user, kdfSettings, orgSsoIdentifier, masterPasswordHint); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgSsoIdentifier) + .ReturnsNull(); + + // Act & Assert + var exception = + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.SetMasterPasswordAsync(user, model)); + Assert.Equal("Organization SSO identifier is invalid.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task OnboardMasterPassword_UserNotFoundInOrganization_ThrowsBadRequestException( + SutProvider sutProvider, + User user, KdfSettings kdfSettings, Organization org, string masterPasswordHint) + { + // Arrange + user.Key = null; + user.PublicKey = "public-key"; + user.PrivateKey = "private-key"; + var model = CreateValidModel(user, kdfSettings, org.Identifier, masterPasswordHint); + + sutProvider.GetDependency() + .GetByIdentifierAsync(org.Identifier) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .ReturnsNull(); + + // Act & Assert + var exception = + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.SetMasterPasswordAsync(user, model)); + Assert.Equal("User not found within organization.", exception.Message); + } + + private static SetInitialMasterPasswordDataModel CreateValidModel( + User user, KdfSettings kdfSettings, string orgSsoIdentifier, string? masterPasswordHint) + { + var salt = user.GetMasterPasswordSalt(); + return new SetInitialMasterPasswordDataModel + { + MasterPasswordAuthentication = + new MasterPasswordAuthenticationData + { + Salt = salt, + MasterPasswordAuthenticationHash = "hash", + Kdf = kdfSettings + }, + MasterPasswordUnlock = + new MasterPasswordUnlockData + { + Salt = salt, + MasterKeyWrappedUserKey = "wrapped-key", + Kdf = kdfSettings + }, + AccountKeys = null, + OrgSsoIdentifier = orgSsoIdentifier, + MasterPasswordHint = masterPasswordHint + }; + } +} diff --git a/test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs b/test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs new file mode 100644 index 0000000000..df0e7133ad --- /dev/null +++ b/test/Core.Test/Billing/Licenses/LicenseConstantsTests.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Organizations.Models; +using Xunit; + +namespace Bit.Core.Test.Billing.Licenses; + +public class LicenseConstantsTests +{ + [Fact] + public void OrganizationLicenseConstants_HasConstantForEveryLicenseProperty() + { + // This test ensures that when a new property is added to OrganizationLicense, + // a corresponding constant is added to OrganizationLicenseConstants. + // This is the first step in the license synchronization pipeline: + // Property → Constant → Claim → Extraction → Application + + // 1. Get all public properties from OrganizationLicense + var licenseProperties = typeof(OrganizationLicense) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .ToHashSet(); + + // 2. Get all constants from OrganizationLicenseConstants + var constants = typeof(OrganizationLicenseConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.GetValue(null) as string) + .ToHashSet(); + + // 3. Define properties that don't need constants (internal/computed/non-claims properties) + var excludedProperties = new HashSet + { + "SignatureBytes", // Computed from Signature property + "ValidLicenseVersion", // Internal property, not serialized + "CurrentLicenseFileVersion", // Constant field, not an instance property + "Hash", // Signature-related, not in claims system + "Signature", // Signature-related, not in claims system + "Token", // The JWT itself, not a claim within the token + "Version" // Not in claims system (only in deprecated property-based licenses) + }; + + // 4. Find license properties without corresponding constants + var propertiesWithoutConstants = licenseProperties + .Except(constants) + .Except(excludedProperties) + .OrderBy(p => p) + .ToList(); + + // 5. Build error message with guidance + var errorMessage = ""; + if (propertiesWithoutConstants.Any()) + { + errorMessage = $"The following OrganizationLicense properties don't have constants in OrganizationLicenseConstants:\n"; + errorMessage += string.Join("\n", propertiesWithoutConstants.Select(p => $" - {p}")); + errorMessage += "\n\nPlease add the following constants to OrganizationLicenseConstants:\n"; + foreach (var prop in propertiesWithoutConstants) + { + errorMessage += $" public const string {prop} = nameof({prop});\n"; + } + } + + // 6. Assert - if this fails, the error message guides the developer to add the constant + Assert.True( + !propertiesWithoutConstants.Any(), + $"\n{errorMessage}"); + } +} diff --git a/test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs b/test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs new file mode 100644 index 0000000000..43dc416de1 --- /dev/null +++ b/test/Core.Test/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactoryTests.cs @@ -0,0 +1,92 @@ +using System.Reflection; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Models; +using Bit.Core.Billing.Licenses.Services.Implementations; +using Bit.Core.Models.Business; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Billing.Licenses.Services.Implementations; + +public class OrganizationLicenseClaimsFactoryTests +{ + [Theory, BitAutoData] + public async Task GenerateClaims_CreatesClaimsForAllConstants(Organization organization) + { + // This test ensures that when a constant is added to OrganizationLicenseConstants, + // it is also added to the OrganizationLicenseClaimsFactory to generate claims. + // This is the second step in the license synchronization pipeline: + // Property → Constant → Claim → Extraction → Application + + // 1. Populate all nullable properties to ensure claims can be generated + // The factory only adds claims for properties that have values + organization.Name = "Test Organization"; + organization.BillingEmail = "billing@test.com"; + organization.BusinessName = "Test Business"; + organization.Plan = "Enterprise"; + organization.LicenseKey = "test-license-key"; + organization.Seats = 100; + organization.MaxCollections = 50; + organization.MaxStorageGb = 10; + organization.SmSeats = 25; + organization.SmServiceAccounts = 10; + organization.ExpirationDate = DateTime.UtcNow.AddYears(1); // Ensure org is not expired + + // Create a LicenseContext with a minimal SubscriptionInfo to trigger conditional claims + // ExpirationWithoutGracePeriod is only generated for active, non-trial, annual subscriptions + var licenseContext = new LicenseContext + { + InstallationId = Guid.NewGuid(), + SubscriptionInfo = new SubscriptionInfo + { + Subscription = new SubscriptionInfo.BillingSubscription(null!) + { + TrialEndDate = DateTime.UtcNow.AddDays(-30), // Trial ended in the past + PeriodStartDate = DateTime.UtcNow, + PeriodEndDate = DateTime.UtcNow.AddDays(365), // Annual subscription (>180 days) + Status = "active" + } + } + }; + + // 2. Generate claims + var factory = new OrganizationLicenseClaimsFactory(); + var claims = await factory.GenerateClaims(organization, licenseContext); + + // 3. Get all constants from OrganizationLicenseConstants + var allConstants = typeof(OrganizationLicenseConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.GetValue(null) as string) + .ToHashSet(); + + // 4. Get claim types from generated claims + var generatedClaimTypes = claims.Select(c => c.Type).ToHashSet(); + + // 5. Find constants that don't have corresponding claims + var constantsWithoutClaims = allConstants + .Except(generatedClaimTypes) + .OrderBy(c => c) + .ToList(); + + // 6. Build error message with guidance + var errorMessage = ""; + if (constantsWithoutClaims.Any()) + { + errorMessage = $"The following constants in OrganizationLicenseConstants are NOT generated as claims in OrganizationLicenseClaimsFactory:\n"; + errorMessage += string.Join("\n", constantsWithoutClaims.Select(c => $" - {c}")); + errorMessage += "\n\nPlease add the following claims to OrganizationLicenseClaimsFactory.GenerateClaims():\n"; + foreach (var constant in constantsWithoutClaims) + { + errorMessage += $" new(nameof(OrganizationLicenseConstants.{constant}), entity.{constant}.ToString()),\n"; + } + errorMessage += "\nNote: If the property is nullable, you may need to add it conditionally."; + } + + // 7. Assert - if this fails, the error message guides the developer to add claim generation + Assert.True( + !constantsWithoutClaims.Any(), + $"\n{errorMessage}"); + } +} diff --git a/test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs b/test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs index d1f02af50d..e5562b53ea 100644 --- a/test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs +++ b/test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs @@ -214,6 +214,7 @@ If you believe you need to change the version for a valid reason, please discuss AllowAdminAccessToAllCollectionItems = true, UseOrganizationDomains = true, UseAdminSponsoredFamilies = false, + UseDisableSmAdsForUsers = false, UsePhishingBlocker = false, }; } @@ -260,4 +261,34 @@ If you believe you need to change the version for a valid reason, please discuss .Returns([0x00, 0x01, 0x02, 0x03]); // Dummy signature for hash testing return mockService; } + + /// + /// Verifies that UseDisableSmAdsForUsers claim is properly generated in the license Token + /// and that VerifyData correctly validates the claim. + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public void OrganizationLicense_UseDisableSmAdsForUsers_ClaimGenerationAndValidation(bool useDisableSmAdsForUsers, ClaimsPrincipal claimsPrincipal) + { + // Arrange + var organization = CreateDeterministicOrganization(); + organization.UseDisableSmAdsForUsers = useDisableSmAdsForUsers; + + var subscriptionInfo = CreateDeterministicSubscriptionInfo(); + var installationId = new Guid("78900000-0000-0000-0000-000000000123"); + var mockLicensingService = CreateMockLicensingService(); + + var license = new OrganizationLicense(organization, subscriptionInfo, installationId, mockLicensingService); + license.Expires = DateTime.MaxValue; // Prevent expiration during test + + var globalSettings = Substitute.For(); + globalSettings.Installation.Returns(new GlobalSettings.InstallationSettings + { + Id = installationId + }); + + // Act & Assert - Verify VerifyData passes with the UseDisableSmAdsForUsers value + Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings)); + } } diff --git a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs index 4cb4caae46..46c418e913 100644 --- a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationLicenseCommandTests.cs @@ -1,9 +1,14 @@ -using System.Security.Claims; +using System.Reflection; +using System.Security.Claims; +using System.Text.RegularExpressions; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses; using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations; using Bit.Core.Services; using Bit.Core.Settings; @@ -88,7 +93,7 @@ public class UpdateOrganizationLicenseCommandTests "Hash", "Signature", "SignatureBytes", "InstallationId", "Expires", "ExpirationWithoutGracePeriod", "Token", "LimitCollectionCreationDeletion", "LimitCollectionCreation", "LimitCollectionDeletion", "AllowAdminAccessToAllCollectionItems", - "UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation", "UsePhishingBlocker") && + "UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation", "UsePhishingBlocker", "UseDisableSmAdsForUsers") && // Same property but different name, use explicit mapping org.ExpirationDate == license.Expires)); } @@ -99,6 +104,320 @@ public class UpdateOrganizationLicenseCommandTests } } + [Theory, BitAutoData] + public async Task UpdateLicenseAsync_WithClaimsPrincipal_ExtractsAllPropertiesFromClaims( + SelfHostedOrganizationDetails selfHostedOrg, + OrganizationLicense license, + SutProvider sutProvider) + { + var globalSettings = sutProvider.GetDependency(); + globalSettings.LicenseDirectory = LicenseDirectory; + globalSettings.SelfHosted = true; + + // Setup license for CanUse validation + license.Enabled = true; + license.Issued = DateTime.Now.AddDays(-1); + license.Expires = DateTime.Now.AddDays(1); + license.Version = OrganizationLicense.CurrentLicenseFileVersion; + license.InstallationId = globalSettings.Installation.Id; + license.LicenseType = LicenseType.Organization; + license.Token = "test-token"; // Indicates this is a claims-based license + sutProvider.GetDependency().VerifyLicense(license).Returns(true); + + // Create a ClaimsPrincipal with all organization license claims + var claims = new List + { + new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()), + new(OrganizationLicenseConstants.InstallationId, globalSettings.Installation.Id.ToString()), + new(OrganizationLicenseConstants.Name, "Test Organization"), + new(OrganizationLicenseConstants.BillingEmail, "billing@test.com"), + new(OrganizationLicenseConstants.BusinessName, "Test Business"), + new(OrganizationLicenseConstants.PlanType, ((int)PlanType.EnterpriseAnnually).ToString()), + new(OrganizationLicenseConstants.Seats, "100"), + new(OrganizationLicenseConstants.MaxCollections, "50"), + new(OrganizationLicenseConstants.UsePolicies, "true"), + new(OrganizationLicenseConstants.UseSso, "true"), + new(OrganizationLicenseConstants.UseKeyConnector, "true"), + new(OrganizationLicenseConstants.UseScim, "true"), + new(OrganizationLicenseConstants.UseGroups, "true"), + new(OrganizationLicenseConstants.UseDirectory, "true"), + new(OrganizationLicenseConstants.UseEvents, "true"), + new(OrganizationLicenseConstants.UseTotp, "true"), + new(OrganizationLicenseConstants.Use2fa, "true"), + new(OrganizationLicenseConstants.UseApi, "true"), + new(OrganizationLicenseConstants.UseResetPassword, "true"), + new(OrganizationLicenseConstants.Plan, "Enterprise"), + new(OrganizationLicenseConstants.SelfHost, "true"), + new(OrganizationLicenseConstants.UsersGetPremium, "true"), + new(OrganizationLicenseConstants.UseCustomPermissions, "true"), + new(OrganizationLicenseConstants.Enabled, "true"), + new(OrganizationLicenseConstants.Expires, DateTime.Now.AddDays(1).ToString("O")), + new(OrganizationLicenseConstants.LicenseKey, "test-license-key"), + new(OrganizationLicenseConstants.UsePasswordManager, "true"), + new(OrganizationLicenseConstants.UseSecretsManager, "true"), + new(OrganizationLicenseConstants.SmSeats, "25"), + new(OrganizationLicenseConstants.SmServiceAccounts, "10"), + new(OrganizationLicenseConstants.UseRiskInsights, "true"), + new(OrganizationLicenseConstants.UseOrganizationDomains, "true"), + new(OrganizationLicenseConstants.UseAdminSponsoredFamilies, "true"), + new(OrganizationLicenseConstants.UseAutomaticUserConfirmation, "true"), + new(OrganizationLicenseConstants.UseDisableSmAdsForUsers, "true"), + new(OrganizationLicenseConstants.UsePhishingBlocker, "true"), + new(OrganizationLicenseConstants.MaxStorageGb, "5"), + new(OrganizationLicenseConstants.Issued, DateTime.Now.AddDays(-1).ToString("O")), + new(OrganizationLicenseConstants.Refresh, DateTime.Now.AddMonths(1).ToString("O")), + new(OrganizationLicenseConstants.ExpirationWithoutGracePeriod, DateTime.Now.AddMonths(12).ToString("O")), + new(OrganizationLicenseConstants.Trial, "false"), + new(OrganizationLicenseConstants.LimitCollectionCreationDeletion, "true"), + new(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems, "true") + }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + sutProvider.GetDependency() + .GetClaimsPrincipalFromLicense(license) + .Returns(claimsPrincipal); + + // Setup selfHostedOrg for CanUseLicense validation + selfHostedOrg.OccupiedSeatCount = 50; // Less than the 100 seats in the license + selfHostedOrg.CollectionCount = 10; // Less than the 50 max collections in the license + selfHostedOrg.GroupCount = 1; + selfHostedOrg.UseGroups = true; + selfHostedOrg.UsePolicies = true; + selfHostedOrg.UseSso = true; + selfHostedOrg.UseKeyConnector = true; + selfHostedOrg.UseScim = true; + selfHostedOrg.UseCustomPermissions = true; + selfHostedOrg.UseResetPassword = true; + + try + { + await sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null); + + // Assertion: license file should be written to disk + var filePath = Path.Combine(LicenseDirectory, "organization", $"{selfHostedOrg.Id}.json"); + await using var fs = File.OpenRead(filePath); + var licenseFromFile = await JsonSerializer.DeserializeAsync(fs); + + AssertHelper.AssertPropertyEqual(license, licenseFromFile, "SignatureBytes"); + + // Assertion: organization should be updated with ALL properties extracted from claims + await sutProvider.GetDependency() + .Received(1) + .ReplaceAndUpdateCacheAsync(Arg.Is(org => + org.Name == "Test Organization" && + org.BillingEmail == "billing@test.com" && + org.BusinessName == "Test Business" && + org.PlanType == PlanType.EnterpriseAnnually && + org.Seats == 100 && + org.MaxCollections == 50 && + org.UsePolicies == true && + org.UseSso == true && + org.UseKeyConnector == true && + org.UseScim == true && + org.UseGroups == true && + org.UseDirectory == true && + org.UseEvents == true && + org.UseTotp == true && + org.Use2fa == true && + org.UseApi == true && + org.UseResetPassword == true && + org.Plan == "Enterprise" && + org.SelfHost == true && + org.UsersGetPremium == true && + org.UseCustomPermissions == true && + org.Enabled == true && + org.LicenseKey == "test-license-key" && + org.UsePasswordManager == true && + org.UseSecretsManager == true && + org.SmSeats == 25 && + org.SmServiceAccounts == 10 && + org.UseRiskInsights == true && + org.UseOrganizationDomains == true && + org.UseAdminSponsoredFamilies == true && + org.UseAutomaticUserConfirmation == true && + org.UseDisableSmAdsForUsers == true && + org.UsePhishingBlocker == true)); + } + finally + { + // Clean up temporary directory + if (Directory.Exists(OrganizationLicenseDirectory.Value)) + { + Directory.Delete(OrganizationLicenseDirectory.Value, true); + } + } + } + + [Theory, BitAutoData] + public async Task UpdateLicenseAsync_WrongInstallationIdInClaims_ThrowsBadRequestException( + SelfHostedOrganizationDetails selfHostedOrg, + OrganizationLicense license, + SutProvider sutProvider) + { + var globalSettings = sutProvider.GetDependency(); + globalSettings.LicenseDirectory = LicenseDirectory; + globalSettings.SelfHosted = true; + + // Setup license for CanUse validation + license.Enabled = true; + license.Issued = DateTime.Now.AddDays(-1); + license.Expires = DateTime.Now.AddDays(1); + license.Version = OrganizationLicense.CurrentLicenseFileVersion; + license.LicenseType = LicenseType.Organization; + license.Token = "test-token"; // Indicates this is a claims-based license + sutProvider.GetDependency().VerifyLicense(license).Returns(true); + + // Create a ClaimsPrincipal with WRONG installation ID + var wrongInstallationId = Guid.NewGuid(); // Different from globalSettings.Installation.Id + var claims = new List + { + new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()), + new(OrganizationLicenseConstants.InstallationId, wrongInstallationId.ToString()), + new(OrganizationLicenseConstants.Enabled, "true"), + new(OrganizationLicenseConstants.SelfHost, "true") + }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + sutProvider.GetDependency() + .GetClaimsPrincipalFromLicense(license) + .Returns(claimsPrincipal); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null)); + + Assert.Contains("The installation ID does not match the current installation.", exception.Message); + + // Verify organization was NOT saved + await sutProvider.GetDependency() + .DidNotReceive() + .ReplaceAndUpdateCacheAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateLicenseAsync_ExpiredLicenseWithoutClaims_ThrowsBadRequestException( + SelfHostedOrganizationDetails selfHostedOrg, + OrganizationLicense license, + SutProvider sutProvider) + { + var globalSettings = sutProvider.GetDependency(); + globalSettings.LicenseDirectory = LicenseDirectory; + globalSettings.SelfHosted = true; + + // Setup legacy license (no Token, no claims) + license.Token = null; // Legacy license + license.Enabled = true; + license.Issued = DateTime.Now.AddDays(-2); + license.Expires = DateTime.Now.AddDays(-1); // Expired yesterday + license.Version = OrganizationLicense.CurrentLicenseFileVersion; + license.InstallationId = globalSettings.Installation.Id; + license.LicenseType = LicenseType.Organization; + license.SelfHost = true; + + sutProvider.GetDependency().VerifyLicense(license).Returns(true); + sutProvider.GetDependency() + .GetClaimsPrincipalFromLicense(license) + .Returns((ClaimsPrincipal)null); // No claims for legacy license + + // Passing values for SelfHostedOrganizationDetails.CanUseLicense + license.Seats = null; + license.MaxCollections = null; + license.UseGroups = true; + license.UsePolicies = true; + license.UseSso = true; + license.UseKeyConnector = true; + license.UseScim = true; + license.UseCustomPermissions = true; + license.UseResetPassword = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null)); + + Assert.Contains("The license has expired.", exception.Message); + + // Verify organization was NOT saved + await sutProvider.GetDependency() + .DidNotReceive() + .ReplaceAndUpdateCacheAsync(Arg.Any()); + } + + [Fact] + public async Task UpdateLicenseAsync_ExtractsAllClaimsBasedProperties_WhenClaimsPrincipalProvided() + { + // This test ensures that when new properties are added to OrganizationLicense, + // they are automatically extracted from JWT claims in UpdateOrganizationLicenseCommand. + // If a new constant is added to OrganizationLicenseConstants but not extracted, + // this test will fail with a clear message showing which properties are missing. + + // 1. Get all OrganizationLicenseConstants + var constantFields = typeof(OrganizationLicenseConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.GetField) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.GetValue(null) as string) + .ToList(); + + // 2. Define properties that should be excluded (not claims-based or intentionally not extracted) + var excludedProperties = new HashSet + { + "Version", // Not in claims system (only in deprecated property-based licenses) + "Hash", // Signature-related, not extracted from claims + "Signature", // Signature-related, not extracted from claims + "SignatureBytes", // Computed from Signature, not a claim + "Token", // The JWT itself, not extracted from claims + "Id" // Cloud org ID from license, not used - self-hosted org has its own separate ID + }; + + // 3. Get properties that should be extracted from claims + var propertiesThatShouldBeExtracted = constantFields + .Where(c => !excludedProperties.Contains(c)) + .ToHashSet(); + + // 4. Read UpdateOrganizationLicenseCommand source code + var commandSourcePath = Path.Combine( + Directory.GetCurrentDirectory(), + "..", "..", "..", "..", "..", + "src", "Core", "Billing", "Organizations", "Commands", "UpdateOrganizationLicenseCommand.cs"); + var sourceCode = await File.ReadAllTextAsync(commandSourcePath); + + // 5. Find all GetValue calls that extract properties from claims + // Pattern matches: license.PropertyName = claimsPrincipal.GetValue(OrganizationLicenseConstants.PropertyName) + var extractedProperties = new HashSet(); + var getValuePattern = @"claimsPrincipal\.GetValue<[^>]+>\(OrganizationLicenseConstants\.(\w+)\)"; + var matches = Regex.Matches(sourceCode, getValuePattern); + + foreach (Match match in matches) + { + extractedProperties.Add(match.Groups[1].Value); + } + + // 6. Find missing extractions + var missingExtractions = propertiesThatShouldBeExtracted + .Except(extractedProperties) + .OrderBy(p => p) + .ToList(); + + // 7. Build error message with guidance if there are missing extractions + var errorMessage = ""; + if (missingExtractions.Any()) + { + errorMessage = $"The following constants in OrganizationLicenseConstants are NOT extracted from claims in UpdateOrganizationLicenseCommand:\n"; + errorMessage += string.Join("\n", missingExtractions.Select(p => $" - {p}")); + errorMessage += "\n\nPlease add the following lines to UpdateOrganizationLicenseCommand.cs in the 'if (claimsPrincipal != null)' block:\n"; + foreach (var prop in missingExtractions) + { + errorMessage += $" license.{prop} = claimsPrincipal.GetValue(OrganizationLicenseConstants.{prop});\n"; + } + } + + // 8. Assert - if this fails, the error message guides the developer to add the extraction + // Note: We don't check for "extra extractions" because that would be a compile error + // (can't reference OrganizationLicenseConstants.Foo if Foo doesn't exist) + Assert.True( + !missingExtractions.Any(), + $"\n{errorMessage}"); + } + // Wrapper to compare 2 objects that are different types private bool AssertPropertyEqual(OrganizationLicense expected, Organization actual, params string[] excludedPropertyStrings) { diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index b58b5cd250..da287dc02b 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Platform.Push; using Bit.Core.Services; @@ -29,6 +30,7 @@ namespace Bit.Core.Test.Billing.Premium.Commands; public class CreatePremiumCloudHostedSubscriptionCommandTests { private readonly IBraintreeGateway _braintreeGateway = Substitute.For(); + private readonly IBraintreeService _braintreeService = Substitute.For(); private readonly IGlobalSettings _globalSettings = Substitute.For(); private readonly ISetupIntentCache _setupIntentCache = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); @@ -59,6 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests _command = new CreatePremiumCloudHostedSubscriptionCommand( _braintreeGateway, + _braintreeService, _globalSettings, _setupIntentCache, _stripeAdapter, @@ -235,11 +238,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; - mockCustomer.Metadata = new Dictionary(); + mockCustomer.Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123" + }; var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "active"; + mockSubscription.LatestInvoiceId = "in_123"; var mockInvoice = Substitute.For(); @@ -258,6 +265,12 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests await _stripeAdapter.Received(1).CreateCustomerAsync(Arg.Any()); await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any()); await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token); + await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId, + Arg.Is(opts => + opts.AutoAdvance == false && + opts.Expand != null && + opts.Expand.Contains("customer"))); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice); await _userService.Received(1).SaveUserAsync(user); await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); } @@ -456,11 +469,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; - mockCustomer.Metadata = new Dictionary(); + mockCustomer.Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123" + }; var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "incomplete"; + mockSubscription.LatestInvoiceId = "in_123"; mockSubscription.Items = new StripeList { Data = @@ -487,6 +504,12 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests Assert.True(result.IsT0); Assert.True(user.Premium); Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate); + await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId, + Arg.Is(opts => + opts.AutoAdvance == false && + opts.Expand != null && + opts.Expand.Contains("customer"))); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice); } [Theory, BitAutoData] @@ -559,11 +582,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; - mockCustomer.Metadata = new Dictionary(); + mockCustomer.Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123" + }; var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "active"; // PayPal + active doesn't match pattern + mockSubscription.LatestInvoiceId = "in_123"; mockSubscription.Items = new StripeList { Data = @@ -590,6 +617,12 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests Assert.True(result.IsT0); Assert.False(user.Premium); Assert.Null(user.PremiumExpirationDate); + await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId, + Arg.Is(opts => + opts.AutoAdvance == false && + opts.Expand != null && + opts.Expand.Contains("customer"))); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice); } [Theory, BitAutoData] diff --git a/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs index 7e3ea562d6..cd9b323f9d 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs @@ -1,6 +1,7 @@ using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; using Bit.Core.Entities; using Bit.Core.Services; using Bit.Test.Common.AutoFixture.Attributes; @@ -8,6 +9,7 @@ using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable; @@ -15,16 +17,15 @@ namespace Bit.Core.Test.Billing.Premium.Commands; public class UpdatePremiumStorageCommandTests { + private readonly IBraintreeService _braintreeService = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly IUserService _userService = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); - private readonly PremiumPlan _premiumPlan; private readonly UpdatePremiumStorageCommand _command; public UpdatePremiumStorageCommandTests() { - // Setup default premium plan with standard pricing - _premiumPlan = new PremiumPlan + var premiumPlan = new PremiumPlan { Name = "Premium", Available = true, @@ -32,29 +33,31 @@ public class UpdatePremiumStorageCommandTests Seat = new PremiumPurchasable { Price = 10M, StripePriceId = "price_premium", Provided = 1 }, Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "price_storage", Provided = 1 } }; - _pricingClient.ListPremiumPlans().Returns(new List { _premiumPlan }); + _pricingClient.ListPremiumPlans().Returns([premiumPlan]); _command = new UpdatePremiumStorageCommand( + _braintreeService, _stripeAdapter, _userService, _pricingClient, Substitute.For>()); } - private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null) + private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null, bool isPayPal = false) { - var items = new List(); - - // Always add the seat item - items.Add(new SubscriptionItem + var items = new List { - Id = "si_seat", - Price = new Price { Id = "price_premium" }, - Quantity = 1 - }); + // Always add the seat item + new() + { + Id = "si_seat", + Price = new Price { Id = "price_premium" }, + Quantity = 1 + } + }; // Add storage item if quantity is provided - if (storageQuantity.HasValue && storageQuantity.Value > 0) + if (storageQuantity is > 0) { items.Add(new SubscriptionItem { @@ -64,9 +67,17 @@ public class UpdatePremiumStorageCommandTests }); } + var customer = new Customer + { + Id = "cus_123", + Metadata = isPayPal ? new Dictionary { { MetadataKeys.BraintreeCustomerId, "braintree_123" } } : new Dictionary() + }; + return new Subscription { Id = subscriptionId, + CustomerId = "cus_123", + Customer = customer, Items = new StripeList { Data = items @@ -98,7 +109,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, -5); @@ -118,7 +129,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 100); @@ -142,7 +153,7 @@ public class UpdatePremiumStorageCommandTests // Assert Assert.True(result.IsT1); var badRequest = result.AsT1; - Assert.Equal("No access to storage.", badRequest.Response); + Assert.Equal("User has no access to storage.", badRequest.Response); } [Theory, BitAutoData] @@ -155,7 +166,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 9); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 0); @@ -177,7 +188,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 4); @@ -186,7 +197,7 @@ public class UpdatePremiumStorageCommandTests Assert.True(result.IsT0); // Verify subscription was fetched but NOT updated - await _stripeAdapter.Received(1).GetSubscriptionAsync("sub_123"); + await _stripeAdapter.Received(1).GetSubscriptionAsync("sub_123", Arg.Any()); await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any(), Arg.Any()); await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); } @@ -201,7 +212,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 9); @@ -216,7 +227,7 @@ public class UpdatePremiumStorageCommandTests opts.Items.Count == 1 && opts.Items[0].Id == "si_storage" && opts.Items[0].Quantity == 9 && - opts.ProrationBehavior == "create_prorations")); + opts.ProrationBehavior == "always_invoice")); // Verify user was saved await _userService.Received(1).SaveUserAsync(Arg.Is(u => @@ -233,8 +244,8 @@ public class UpdatePremiumStorageCommandTests user.Storage = 500L * 1024 * 1024; user.GatewaySubscriptionId = "sub_123"; - var subscription = CreateMockSubscription("sub_123", null); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + var subscription = CreateMockSubscription("sub_123"); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 9); @@ -263,7 +274,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 9); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 2); @@ -292,7 +303,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 9); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 0); @@ -321,7 +332,7 @@ public class UpdatePremiumStorageCommandTests user.GatewaySubscriptionId = "sub_123"; var subscription = CreateMockSubscription("sub_123", 4); - _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); // Act var result = await _command.Run(user, 99); @@ -336,4 +347,200 @@ public class UpdatePremiumStorageCommandTests await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 100)); } + + [Theory, BitAutoData] + public async Task Run_IncreaseStorage_PayPal_Success(User user) + { + // Arrange + user.Premium = true; + user.MaxStorageGb = 5; + user.Storage = 2L * 1024 * 1024 * 1024; + user.GatewaySubscriptionId = "sub_123"; + + var subscription = CreateMockSubscription("sub_123", 4, isPayPal: true); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); + + var draftInvoice = new Invoice { Id = "in_draft" }; + _stripeAdapter.CreateInvoiceAsync(Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice + { + Id = "in_finalized", + Customer = new Customer { Id = "cus_123" } + }; + _stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any()).Returns(finalizedInvoice); + + // Act + var result = await _command.Run(user, 9); + + // Assert + Assert.True(result.IsT0); + + // Verify subscription was updated with CreateProrations + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 1 && + opts.Items[0].Id == "si_storage" && + opts.Items[0].Quantity == 9 && + opts.ProrationBehavior == "create_prorations")); + + // Verify draft invoice was created + await _stripeAdapter.Received(1).CreateInvoiceAsync( + Arg.Is(opts => + opts.Customer == "cus_123" && + opts.Subscription == "sub_123" && + opts.AutoAdvance == false && + opts.CollectionMethod == "charge_automatically")); + + // Verify invoice was finalized + await _stripeAdapter.Received(1).FinalizeInvoiceAsync( + "in_draft", + Arg.Is(opts => + opts.AutoAdvance == false && + opts.Expand.Contains("customer"))); + + // Verify Braintree payment was processed + await _braintreeService.Received(1).PayInvoice(Arg.Any(), finalizedInvoice); + + // Verify user was saved + await _userService.Received(1).SaveUserAsync(Arg.Is(u => + u.Id == user.Id && + u.MaxStorageGb == 10)); + } + + [Theory, BitAutoData] + public async Task Run_AddStorageFromZero_PayPal_Success(User user) + { + // Arrange + user.Premium = true; + user.MaxStorageGb = 1; + user.Storage = 500L * 1024 * 1024; + user.GatewaySubscriptionId = "sub_123"; + + var subscription = CreateMockSubscription("sub_123", isPayPal: true); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); + + var draftInvoice = new Invoice { Id = "in_draft" }; + _stripeAdapter.CreateInvoiceAsync(Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice + { + Id = "in_finalized", + Customer = new Customer { Id = "cus_123" } + }; + _stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any()).Returns(finalizedInvoice); + + // Act + var result = await _command.Run(user, 9); + + // Assert + Assert.True(result.IsT0); + + // Verify subscription was updated with new storage item + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 1 && + opts.Items[0].Price == "price_storage" && + opts.Items[0].Quantity == 9 && + opts.ProrationBehavior == "create_prorations")); + + // Verify invoice creation and payment flow + await _stripeAdapter.Received(1).CreateInvoiceAsync(Arg.Any()); + await _stripeAdapter.Received(1).FinalizeInvoiceAsync("in_draft", Arg.Any()); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), finalizedInvoice); + + await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 10)); + } + + [Theory, BitAutoData] + public async Task Run_DecreaseStorage_PayPal_Success(User user) + { + // Arrange + user.Premium = true; + user.MaxStorageGb = 10; + user.Storage = 2L * 1024 * 1024 * 1024; + user.GatewaySubscriptionId = "sub_123"; + + var subscription = CreateMockSubscription("sub_123", 9, isPayPal: true); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); + + var draftInvoice = new Invoice { Id = "in_draft" }; + _stripeAdapter.CreateInvoiceAsync(Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice + { + Id = "in_finalized", + Customer = new Customer { Id = "cus_123" } + }; + _stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any()).Returns(finalizedInvoice); + + // Act + var result = await _command.Run(user, 2); + + // Assert + Assert.True(result.IsT0); + + // Verify subscription was updated + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 1 && + opts.Items[0].Id == "si_storage" && + opts.Items[0].Quantity == 2 && + opts.ProrationBehavior == "create_prorations")); + + // Verify invoice creation and payment flow + await _stripeAdapter.Received(1).CreateInvoiceAsync(Arg.Any()); + await _stripeAdapter.Received(1).FinalizeInvoiceAsync("in_draft", Arg.Any()); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), finalizedInvoice); + + await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 3)); + } + + [Theory, BitAutoData] + public async Task Run_RemoveAllAdditionalStorage_PayPal_Success(User user) + { + // Arrange + user.Premium = true; + user.MaxStorageGb = 10; + user.Storage = 500L * 1024 * 1024; + user.GatewaySubscriptionId = "sub_123"; + + var subscription = CreateMockSubscription("sub_123", 9, isPayPal: true); + _stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any()).Returns(subscription); + + var draftInvoice = new Invoice { Id = "in_draft" }; + _stripeAdapter.CreateInvoiceAsync(Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice + { + Id = "in_finalized", + Customer = new Customer { Id = "cus_123" } + }; + _stripeAdapter.FinalizeInvoiceAsync("in_draft", Arg.Any()).Returns(finalizedInvoice); + + // Act + var result = await _command.Run(user, 0); + + // Assert + Assert.True(result.IsT0); + + // Verify subscription item was deleted + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 1 && + opts.Items[0].Id == "si_storage" && + opts.Items[0].Deleted == true && + opts.ProrationBehavior == "create_prorations")); + + // Verify invoice creation and payment flow + await _stripeAdapter.Received(1).CreateInvoiceAsync(Arg.Any()); + await _stripeAdapter.Received(1).FinalizeInvoiceAsync("in_draft", Arg.Any()); + await _braintreeService.Received(1).PayInvoice(Arg.Any(), finalizedInvoice); + + await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 1)); + } } diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs new file mode 100644 index 0000000000..e686d04009 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -0,0 +1,646 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; +using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable; + +namespace Bit.Core.Test.Billing.Premium.Commands; + +public class UpgradePremiumToOrganizationCommandTests +{ + // Concrete test implementation of the abstract Plan record + private record TestPlan : Core.Models.StaticStore.Plan + { + public TestPlan( + PlanType planType, + string? stripePlanId = null, + string? stripeSeatPlanId = null, + string? stripePremiumAccessPlanId = null, + string? stripeStoragePlanId = null) + { + Type = planType; + ProductTier = ProductTierType.Teams; + Name = "Test Plan"; + IsAnnual = true; + NameLocalizationKey = ""; + DescriptionLocalizationKey = ""; + CanBeUsedByBusiness = true; + TrialPeriodDays = null; + HasSelfHost = false; + HasPolicies = false; + HasGroups = false; + HasDirectory = false; + HasEvents = false; + HasTotp = false; + Has2fa = false; + HasApi = false; + HasSso = false; + HasOrganizationDomains = false; + HasKeyConnector = false; + HasScim = false; + HasResetPassword = false; + UsersGetPremium = false; + HasCustomPermissions = false; + UpgradeSortOrder = 0; + DisplaySortOrder = 0; + LegacyYear = null; + Disabled = false; + PasswordManager = new PasswordManagerPlanFeatures + { + StripePlanId = stripePlanId, + StripeSeatPlanId = stripeSeatPlanId, + StripePremiumAccessPlanId = stripePremiumAccessPlanId, + StripeStoragePlanId = stripeStoragePlanId, + BasePrice = 0, + SeatPrice = 0, + ProviderPortalSeatPrice = 0, + AllowSeatAutoscale = true, + HasAdditionalSeatsOption = true, + BaseSeats = 1, + HasPremiumAccessOption = !string.IsNullOrEmpty(stripePremiumAccessPlanId), + PremiumAccessOptionPrice = 0, + MaxSeats = null, + BaseStorageGb = 1, + HasAdditionalStorageOption = !string.IsNullOrEmpty(stripeStoragePlanId), + AdditionalStoragePricePerGb = 0, + MaxCollections = null + }; + SecretsManager = null; + } + } + + private static Core.Models.StaticStore.Plan CreateTestPlan( + PlanType planType, + string? stripePlanId = null, + string? stripeSeatPlanId = null, + string? stripePremiumAccessPlanId = null, + string? stripeStoragePlanId = null) + { + return new TestPlan(planType, stripePlanId, stripeSeatPlanId, stripePremiumAccessPlanId, stripeStoragePlanId); + } + + private static PremiumPlan CreateTestPremiumPlan( + string seatPriceId = "premium-annually", + string storagePriceId = "personal-storage-gb-annually", + bool available = true) + { + return new PremiumPlan + { + Name = "Premium", + LegacyYear = null, + Available = available, + Seat = new PremiumPurchasable + { + StripePriceId = seatPriceId, + Price = 10m, + Provided = 1 + }, + Storage = new PremiumPurchasable + { + StripePriceId = storagePriceId, + Price = 4m, + Provided = 1 + } + }; + } + + private static List CreateTestPremiumPlansList() + { + return new List + { + // Current available plan + CreateTestPremiumPlan("premium-annually", "personal-storage-gb-annually", available: true), + // Legacy plan from 2020 + CreateTestPremiumPlan("premium-annually-2020", "personal-storage-gb-annually-2020", available: false) + }; + } + + + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly IUserService _userService = Substitute.For(); + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); + private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For(); + private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository = Substitute.For(); + private readonly IApplicationCacheService _applicationCacheService = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); + private readonly UpgradePremiumToOrganizationCommand _command; + + public UpgradePremiumToOrganizationCommandTests() + { + _command = new UpgradePremiumToOrganizationCommand( + _logger, + _pricingClient, + _stripeAdapter, + _userService, + _organizationRepository, + _organizationUserRepository, + _organizationApiKeyRepository, + _applicationCacheService); + } + + [Theory, BitAutoData] + public async Task Run_UserNotPremium_ReturnsBadRequest(User user) + { + // Arrange + user.Premium = false; + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("User does not have an active Premium subscription.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_UserNoGatewaySubscriptionId_ReturnsBadRequest(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = null; + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("User does not have an active Premium subscription.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_UserEmptyGatewaySubscriptionId_ReturnsBadRequest(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = ""; + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("User does not have an active Premium subscription.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_SuccessfulUpgrade_SeatBasedPlan_ReturnsSuccess(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + user.Id = Guid.NewGuid(); + + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" }, + CurrentPeriodEnd = currentPeriodEnd + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan( + PlanType.TeamsAnnually, + stripeSeatPlanId: "teams-seat-annually" + ); + + _stripeAdapter.GetSubscriptionAsync("sub_123") + .Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockSubscription)); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + + // Assert + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 2 && // 1 deleted + 1 seat (no storage) + opts.Items.Any(i => i.Deleted == true) && + opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); + + await _organizationRepository.Received(1).CreateAsync(Arg.Is(o => + o.Name == "My Organization" && + o.Gateway == GatewayType.Stripe && + o.GatewaySubscriptionId == "sub_123" && + o.GatewayCustomerId == "cus_123")); + await _organizationUserRepository.Received(1).CreateAsync(Arg.Is(ou => + ou.Key == "encrypted-key" && + ou.Status == OrganizationUserStatusType.Confirmed)); + await _organizationApiKeyRepository.Received(1).CreateAsync(Arg.Any()); + + await _userService.Received(1).SaveUserAsync(Arg.Is(u => + u.Premium == false && + u.GatewaySubscriptionId == null && + u.GatewayCustomerId == null)); + } + + [Theory, BitAutoData] + public async Task Run_SuccessfulUpgrade_NonSeatBasedPlan_ReturnsSuccess(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" }, + CurrentPeriodEnd = currentPeriodEnd + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan( + PlanType.FamiliesAnnually, + stripePlanId: "families-plan-annually", + stripeSeatPlanId: null // Non-seat-based + ); + + _stripeAdapter.GetSubscriptionAsync("sub_123") + .Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockSubscription)); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Families Org", "encrypted-key", PlanType.FamiliesAnnually); + + // Assert + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 2 && // 1 deleted + 1 plan + opts.Items.Any(i => i.Deleted == true) && + opts.Items.Any(i => i.Price == "families-plan-annually" && i.Quantity == 1))); + + await _organizationRepository.Received(1).CreateAsync(Arg.Is(o => + o.Name == "My Families Org")); + await _userService.Received(1).SaveUserAsync(Arg.Is(u => + u.Premium == false && + u.GatewaySubscriptionId == null)); + } + + + [Theory, BitAutoData] + public async Task Run_AddsMetadataWithOriginalPremiumPriceId(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" }, + CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) + } + } + }, + Metadata = new Dictionary + { + ["userId"] = user.Id.ToString() + } + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan( + PlanType.TeamsAnnually, + stripeSeatPlanId: "teams-seat-annually" + ); + + _stripeAdapter.GetSubscriptionAsync("sub_123") + .Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockSubscription)); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + + // Assert + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) && + opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId) && + opts.Metadata[StripeConstants.MetadataKeys.PreviousPremiumPriceId] == "premium-annually" && + opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPeriodEndDate) && + opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) && + opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "0" && + opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UserId) && + opts.Metadata[StripeConstants.MetadataKeys.UserId] == string.Empty)); // Removes userId to unlink from User + } + + [Theory, BitAutoData] + public async Task Run_UserOnLegacyPremiumPlan_SuccessfullyDeletesLegacyItems(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium_legacy", + Price = new Price { Id = "premium-annually-2020" }, // Legacy price ID + CurrentPeriodEnd = currentPeriodEnd + }, + new SubscriptionItem + { + Id = "si_storage_legacy", + Price = new Price { Id = "personal-storage-gb-annually-2020" }, // Legacy storage price ID + CurrentPeriodEnd = currentPeriodEnd + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan( + PlanType.TeamsAnnually, + stripeSeatPlanId: "teams-seat-annually" + ); + + _stripeAdapter.GetSubscriptionAsync("sub_123") + .Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockSubscription)); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + + // Assert + Assert.True(result.IsT0); + + // Verify that BOTH legacy items (password manager + storage) are deleted by ID + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 3 && // 2 deleted (legacy PM + legacy storage) + 1 new seat + opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium_legacy") == 1 && // Legacy PM deleted + opts.Items.Count(i => i.Deleted == true && i.Id == "si_storage_legacy") == 1 && // Legacy storage deleted + opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); + } + + [Theory, BitAutoData] + public async Task Run_UserHasPremiumPlusOtherProducts_OnlyDeletesPremiumItems(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" }, + CurrentPeriodEnd = currentPeriodEnd + }, + new SubscriptionItem + { + Id = "si_other_product", + Price = new Price { Id = "some-other-product-id" }, // Non-premium item + CurrentPeriodEnd = currentPeriodEnd + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan( + PlanType.TeamsAnnually, + stripeSeatPlanId: "teams-seat-annually" + ); + + _stripeAdapter.GetSubscriptionAsync("sub_123") + .Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockSubscription)); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + + // Assert + Assert.True(result.IsT0); + + // Verify that ONLY the premium password manager item is deleted (not other products) + // Note: We delete the specific premium item by ID, so other products are untouched + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Items.Count == 2 && // 1 deleted (premium password manager) + 1 new seat + opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium") == 1 && // Premium item deleted by ID + opts.Items.Count(i => i.Id == "si_other_product") == 0 && // Other product NOT in update (untouched) + opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1))); + } + + [Theory, BitAutoData] + public async Task Run_UserHasAdditionalStorage_CapturesStorageInMetadata(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" }, + CurrentPeriodEnd = currentPeriodEnd + }, + new SubscriptionItem + { + Id = "si_storage", + Price = new Price { Id = "personal-storage-gb-annually" }, + Quantity = 5, // User has 5GB additional storage + CurrentPeriodEnd = currentPeriodEnd + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan( + PlanType.TeamsAnnually, + stripeSeatPlanId: "teams-seat-annually" + ); + + _stripeAdapter.GetSubscriptionAsync("sub_123") + .Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockSubscription)); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + + // Assert + Assert.True(result.IsT0); + + // Verify that the additional storage quantity (5) is captured in metadata + await _stripeAdapter.Received(1).UpdateSubscriptionAsync( + "sub_123", + Arg.Is(opts => + opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) && + opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "5" && + opts.Items.Count == 3 && // 2 deleted (premium + storage) + 1 new seat + opts.Items.Count(i => i.Deleted == true) == 2)); + } + + [Theory, BitAutoData] + public async Task Run_NoPremiumSubscriptionItemFound_ReturnsBadRequest(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_other", + Price = new Price { Id = "some-other-product" }, // Not a premium plan + CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + + _stripeAdapter.GetSubscriptionAsync("sub_123") + .Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Premium subscription item not found.", badRequest.Response); + } +} diff --git a/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs new file mode 100644 index 0000000000..a12a0e4cb0 --- /dev/null +++ b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs @@ -0,0 +1,607 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Models; +using Bit.Core.Billing.Subscriptions.Queries; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Subscriptions.Queries; + +using static StripeConstants; + +public class GetBitwardenSubscriptionQueryTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly GetBitwardenSubscriptionQuery _query; + + public GetBitwardenSubscriptionQueryTests() + { + _query = new GetBitwardenSubscriptionQuery( + _logger, + _pricingClient, + _stripeAdapter); + } + + [Fact] + public async Task Run_IncompleteStatus_ReturnsBitwardenSubscriptionWithSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Incomplete); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Incomplete, result.Status); + Assert.NotNull(result.Suspension); + Assert.Equal(subscription.Created.AddHours(23), result.Suspension); + Assert.Equal(1, result.GracePeriod); + Assert.Null(result.NextCharge); + Assert.Null(result.CancelAt); + } + + [Fact] + public async Task Run_IncompleteExpiredStatus_ReturnsBitwardenSubscriptionWithSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.IncompleteExpired); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.IncompleteExpired, result.Status); + Assert.NotNull(result.Suspension); + Assert.Equal(subscription.Created.AddHours(23), result.Suspension); + Assert.Equal(1, result.GracePeriod); + } + + [Fact] + public async Task Run_TrialingStatus_ReturnsBitwardenSubscriptionWithNextCharge() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Trialing); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Trialing, result.Status); + Assert.NotNull(result.NextCharge); + Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge); + Assert.Null(result.Suspension); + Assert.Null(result.GracePeriod); + } + + [Fact] + public async Task Run_ActiveStatus_ReturnsBitwardenSubscriptionWithNextCharge() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Active, result.Status); + Assert.NotNull(result.NextCharge); + Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge); + Assert.Null(result.Suspension); + Assert.Null(result.GracePeriod); + } + + [Fact] + public async Task Run_ActiveStatusWithCancelAt_ReturnsCancelAt() + { + var user = CreateUser(); + var cancelAt = DateTime.UtcNow.AddMonths(1); + var subscription = CreateSubscription(SubscriptionStatus.Active, cancelAt: cancelAt); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Active, result.Status); + Assert.Equal(cancelAt, result.CancelAt); + } + + [Fact] + public async Task Run_PastDueStatus_WithOpenInvoices_ReturnsSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.PastDue, collectionMethod: "charge_automatically"); + var premiumPlans = CreatePremiumPlans(); + var openInvoice = CreateInvoice(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + _stripeAdapter.SearchInvoiceAsync(Arg.Any()) + .Returns([openInvoice]); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.PastDue, result.Status); + Assert.NotNull(result.Suspension); + Assert.Equal(openInvoice.Created.AddDays(14), result.Suspension); + Assert.Equal(14, result.GracePeriod); + } + + [Fact] + public async Task Run_PastDueStatus_WithoutOpenInvoices_ReturnsNoSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.PastDue); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + _stripeAdapter.SearchInvoiceAsync(Arg.Any()) + .Returns([]); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.PastDue, result.Status); + Assert.Null(result.Suspension); + Assert.Null(result.GracePeriod); + } + + [Fact] + public async Task Run_UnpaidStatus_WithOpenInvoices_ReturnsSuspension() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Unpaid, collectionMethod: "charge_automatically"); + var premiumPlans = CreatePremiumPlans(); + var openInvoice = CreateInvoice(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + _stripeAdapter.SearchInvoiceAsync(Arg.Any()) + .Returns([openInvoice]); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Unpaid, result.Status); + Assert.NotNull(result.Suspension); + Assert.Equal(14, result.GracePeriod); + } + + [Fact] + public async Task Run_CanceledStatus_ReturnsCanceledDate() + { + var user = CreateUser(); + var canceledAt = DateTime.UtcNow.AddDays(-5); + var subscription = CreateSubscription(SubscriptionStatus.Canceled, canceledAt: canceledAt); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(SubscriptionStatus.Canceled, result.Status); + Assert.Equal(canceledAt, result.Canceled); + Assert.Null(result.Suspension); + Assert.Null(result.NextCharge); + } + + [Fact] + public async Task Run_UnmanagedStatus_ThrowsConflictException() + { + var user = CreateUser(); + var subscription = CreateSubscription("unmanaged_status"); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + await Assert.ThrowsAsync(() => _query.Run(user)); + } + + [Fact] + public async Task Run_WithAdditionalStorage_IncludesStorageInCart() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.NotNull(result.Cart.PasswordManager.AdditionalStorage); + Assert.Equal("additionalStorageGB", result.Cart.PasswordManager.AdditionalStorage.TranslationKey); + Assert.Equal(2, result.Cart.PasswordManager.AdditionalStorage.Quantity); + Assert.NotNull(result.Storage); + } + + [Fact] + public async Task Run_WithoutAdditionalStorage_ExcludesStorageFromCart() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: false); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Null(result.Cart.PasswordManager.AdditionalStorage); + Assert.NotNull(result.Storage); + } + + [Fact] + public async Task Run_WithCartLevelDiscount_IncludesDiscountInCart() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + subscription.Customer.Discount = CreateDiscount(discountType: "cart"); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.NotNull(result.Cart.Discount); + Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.Discount.Type); + Assert.Equal(20, result.Cart.Discount.Value); + } + + [Fact] + public async Task Run_WithProductLevelDiscount_IncludesDiscountInCartItem() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var productDiscount = CreateDiscount(discountType: "product", productId: "prod_premium_seat"); + subscription.Discounts = [productDiscount]; + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.NotNull(result.Cart.PasswordManager.Seats.Discount); + Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.PasswordManager.Seats.Discount.Type); + } + + [Fact] + public async Task Run_WithoutMaxStorageGb_ReturnsNullStorage() + { + var user = CreateUser(); + user.MaxStorageGb = null; + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Null(result.Storage); + } + + [Fact] + public async Task Run_CalculatesStorageCorrectly() + { + var user = CreateUser(); + user.Storage = 5368709120; // 5 GB in bytes + user.MaxStorageGb = 10; + var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.NotNull(result.Storage); + Assert.Equal(10, result.Storage.Available); + Assert.Equal(5.0, result.Storage.Used); + Assert.NotEmpty(result.Storage.ReadableUsed); + } + + [Fact] + public async Task Run_TaxEstimation_WithInvoiceUpcomingNoneError_ReturnsZeroTax() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .ThrowsAsync(new StripeException { StripeError = new StripeError { Code = ErrorCodes.InvoiceUpcomingNone } }); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(0, result.Cart.EstimatedTax); + } + + [Fact] + public async Task Run_MissingPasswordManagerSeatsItem_ThrowsConflictException() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + subscription.Items = new StripeList + { + Data = [] + }; + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + + await Assert.ThrowsAsync(() => _query.Run(user)); + } + + [Fact] + public async Task Run_IncludesEstimatedTax() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + var invoice = CreateInvoicePreview(totalTax: 500); // $5.00 tax + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(invoice); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(5.0m, result.Cart.EstimatedTax); + } + + [Fact] + public async Task Run_SetsCadenceToAnnually() + { + var user = CreateUser(); + var subscription = CreateSubscription(SubscriptionStatus.Active); + var premiumPlans = CreatePremiumPlans(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _pricingClient.ListPremiumPlans().Returns(premiumPlans); + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()) + .Returns(CreateInvoicePreview()); + + var result = await _query.Run(user); + + Assert.NotNull(result); + Assert.Equal(PlanCadenceType.Annually, result.Cart.Cadence); + } + + #region Helper Methods + + private static User CreateUser() + { + return new User + { + Id = Guid.NewGuid(), + GatewaySubscriptionId = "sub_test123", + MaxStorageGb = 1, + Storage = 1073741824 // 1 GB in bytes + }; + } + + private static Subscription CreateSubscription( + string status, + bool includeStorage = false, + DateTime? cancelAt = null, + DateTime? canceledAt = null, + string collectionMethod = "charge_automatically") + { + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + var items = new List + { + new() + { + Id = "si_premium_seat", + Price = new Price + { + Id = "price_premium_seat", + UnitAmountDecimal = 1000, + Product = new Product { Id = "prod_premium_seat" } + }, + Quantity = 1, + CurrentPeriodStart = DateTime.UtcNow, + CurrentPeriodEnd = currentPeriodEnd + } + }; + + if (includeStorage) + { + items.Add(new SubscriptionItem + { + Id = "si_storage", + Price = new Price + { + Id = "price_storage", + UnitAmountDecimal = 400, + Product = new Product { Id = "prod_storage" } + }, + Quantity = 2, + CurrentPeriodStart = DateTime.UtcNow, + CurrentPeriodEnd = currentPeriodEnd + }); + } + + return new Subscription + { + Id = "sub_test123", + Status = status, + Created = DateTime.UtcNow.AddMonths(-1), + Customer = new Customer + { + Id = "cus_test123", + Discount = null + }, + Items = new StripeList + { + Data = items + }, + CancelAt = cancelAt, + CanceledAt = canceledAt, + CollectionMethod = collectionMethod, + Discounts = [] + }; + } + + private static List CreatePremiumPlans() + { + return + [ + new() + { + Name = "Premium", + Available = true, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "price_premium_seat", + Price = 10.0m, + Provided = 1 + }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable + { + StripePriceId = "price_storage", + Price = 4.0m, + Provided = 1 + } + } + ]; + } + + private static Invoice CreateInvoice() + { + return new Invoice + { + Id = "in_test123", + Created = DateTime.UtcNow.AddDays(-10), + PeriodEnd = DateTime.UtcNow.AddDays(-5), + Attempted = true, + Status = "open" + }; + } + + private static Invoice CreateInvoicePreview(long totalTax = 0) + { + var taxes = totalTax > 0 + ? new List { new() { Amount = totalTax } } + : new List(); + + return new Invoice + { + Id = "in_preview", + TotalTaxes = taxes + }; + } + + private static Discount CreateDiscount(string discountType = "cart", string? productId = null) + { + var coupon = new Coupon + { + Valid = true, + PercentOff = 20, + AppliesTo = discountType == "product" && productId != null + ? new CouponAppliesTo { Products = [productId] } + : new CouponAppliesTo { Products = [] } + }; + + return new Discount + { + Coupon = coupon + }; + } + + #endregion +} diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj index b9e218205c..243e9af806 100644 --- a/test/Core.Test/Core.Test.csproj +++ b/test/Core.Test/Core.Test.csproj @@ -2,6 +2,8 @@ false Bit.Core.Test + + $(WarningsNotAsErrors);CA1304;CA1305 @@ -30,7 +32,7 @@ - + diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs index 223047ee07..b4f1fe2d98 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Exceptions; @@ -9,6 +12,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; @@ -72,8 +76,12 @@ public class UpgradeOrganizationPlanCommandTests [Theory] [FreeOrganizationUpgradeCustomize, BitAutoData] public async Task UpgradePlan_Passes(Organization organization, OrganizationUpgrade upgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType)); upgrade.AdditionalSmSeats = 10; @@ -100,6 +108,7 @@ public class UpgradeOrganizationPlanCommandTests PlanType planType, Organization organization, OrganizationUpgrade organizationUpgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); @@ -116,6 +125,9 @@ public class UpgradeOrganizationPlanCommandTests organizationUpgrade.Plan = planType; sutProvider.GetDependency().GetPlanOrThrow(organizationUpgrade.Plan).Returns(MockPlans.Get(organizationUpgrade.Plan)); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts { @@ -141,15 +153,20 @@ public class UpgradeOrganizationPlanCommandTests [BitAutoData(PlanType.TeamsAnnually)] [BitAutoData(PlanType.TeamsStarter)] public async Task UpgradePlan_SM_Passes(PlanType planType, Organization organization, OrganizationUpgrade upgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { - sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType)); upgrade.Plan = planType; sutProvider.GetDependency().GetPlanOrThrow(upgrade.Plan).Returns(MockPlans.Get(upgrade.Plan)); var plan = MockPlans.Get(upgrade.Plan); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); + + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType)); sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); upgrade.AdditionalSeats = 15; @@ -180,6 +197,7 @@ public class UpgradeOrganizationPlanCommandTests [BitAutoData(PlanType.TeamsAnnually)] [BitAutoData(PlanType.TeamsStarter)] public async Task UpgradePlan_SM_NotEnoughSmSeats_Throws(PlanType planType, Organization organization, OrganizationUpgrade upgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { upgrade.Plan = planType; @@ -191,6 +209,10 @@ public class UpgradeOrganizationPlanCommandTests organization.SmSeats = 2; sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType)); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts @@ -214,7 +236,9 @@ public class UpgradeOrganizationPlanCommandTests [BitAutoData(PlanType.TeamsAnnually, 51)] [BitAutoData(PlanType.TeamsStarter, 51)] public async Task UpgradePlan_SM_NotEnoughServiceAccounts_Throws(PlanType planType, int currentServiceAccounts, - Organization organization, OrganizationUpgrade upgrade, SutProvider sutProvider) + Organization organization, OrganizationUpgrade upgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, + SutProvider sutProvider) { upgrade.Plan = planType; upgrade.AdditionalSeats = 15; @@ -226,6 +250,10 @@ public class UpgradeOrganizationPlanCommandTests organization.SmServiceAccounts = currentServiceAccounts; sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType)); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts @@ -251,6 +279,7 @@ public class UpgradeOrganizationPlanCommandTests OrganizationUpgrade upgrade, string newPublicKey, string newPrivateKey, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { organization.PublicKey = null; @@ -262,6 +291,9 @@ public class UpgradeOrganizationPlanCommandTests publicKey: newPublicKey); upgrade.AdditionalSeats = 10; + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); sutProvider.GetDependency() .GetByIdAsync(organization.Id) .Returns(organization); @@ -291,6 +323,7 @@ public class UpgradeOrganizationPlanCommandTests public async Task UpgradePlan_WhenOrganizationAlreadyHasPublicAndPrivateKeys_DoesNotOverwriteWithNull( Organization organization, OrganizationUpgrade upgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { // Arrange @@ -304,6 +337,9 @@ public class UpgradeOrganizationPlanCommandTests upgrade.Keys = null; upgrade.AdditionalSeats = 10; + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); sutProvider.GetDependency() .GetByIdAsync(organization.Id) .Returns(organization); @@ -333,6 +369,7 @@ public class UpgradeOrganizationPlanCommandTests public async Task UpgradePlan_WhenOrganizationAlreadyHasPublicAndPrivateKeys_DoesNotBackfillWithNewKeys( Organization organization, OrganizationUpgrade upgrade, + [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { // Arrange @@ -343,6 +380,9 @@ public class UpgradeOrganizationPlanCommandTests organization.PublicKey = existingPublicKey; organization.PrivateKey = existingPrivateKey; + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(policy); upgrade.Plan = PlanType.TeamsAnnually; upgrade.Keys = new PublicKeyEncryptionKeyPairData( diff --git a/test/Core.Test/OrganizationFeatures/Policies/PolicyQueryTests.cs b/test/Core.Test/OrganizationFeatures/Policies/PolicyQueryTests.cs new file mode 100644 index 0000000000..ac33a5e5a6 --- /dev/null +++ b/test/Core.Test/OrganizationFeatures/Policies/PolicyQueryTests.cs @@ -0,0 +1,55 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; +using Bit.Core.AdminConsole.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Core.Test.OrganizationFeatures.Policies; + +[SutProviderCustomize] +public class PolicyQueryTests +{ + [Theory, BitAutoData] + public async Task RunAsync_WithExistingPolicy_ReturnsPolicy(SutProvider sutProvider, + Policy policy) + { + // Arrange + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policy.OrganizationId, policy.Type) + .Returns(policy); + + // Act + var policyData = await sutProvider.Sut.RunAsync(policy.OrganizationId, policy.Type); + + // Assert + Assert.Equal(policy.Data, policyData.Data); + Assert.Equal(policy.Type, policyData.Type); + Assert.Equal(policy.Enabled, policyData.Enabled); + Assert.Equal(policy.OrganizationId, policyData.OrganizationId); + } + + [Theory, BitAutoData] + public async Task RunAsync_WithNonExistentPolicy_ReturnsDefaultDisabledPolicy( + SutProvider sutProvider, + Guid organizationId, + PolicyType policyType) + { + // Arrange + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(organizationId, policyType) + .ReturnsNull(); + + // Act + var policyData = await sutProvider.Sut.RunAsync(organizationId, policyType); + + // Assert + Assert.Equal(organizationId, policyData.OrganizationId); + Assert.Equal(policyType, policyData.Type); + Assert.False(policyData.Enabled); + Assert.Null(policyData.Data); + } +} diff --git a/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs index e1b98f87d3..0490e77cde 100644 --- a/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs +++ b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs @@ -9,5 +9,5 @@ public class TestMailView : BaseMailView public class TestMail : BaseMail { - public override string Subject { get; } = "Test Email"; + public override string Subject { get; set; } = "Test Email"; } diff --git a/test/Core.Test/Services/PlayIdServiceTests.cs b/test/Core.Test/Services/PlayIdServiceTests.cs new file mode 100644 index 0000000000..7e4977c7bb --- /dev/null +++ b/test/Core.Test/Services/PlayIdServiceTests.cs @@ -0,0 +1,211 @@ +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class PlayIdServiceTests +{ + [Theory] + [BitAutoData] + public void InPlay_WhenPlayIdSetAndDevelopment_ReturnsTrue( + string playId, + SutProvider sutProvider) + { + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Development); + sutProvider.Sut.PlayId = playId; + + var result = sutProvider.Sut.InPlay(out var resultPlayId); + + Assert.True(result); + Assert.Equal(playId, resultPlayId); + } + + [Theory] + [BitAutoData] + public void InPlay_WhenPlayIdSetButNotDevelopment_ReturnsFalse( + string playId, + SutProvider sutProvider) + { + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Production); + sutProvider.Sut.PlayId = playId; + + var result = sutProvider.Sut.InPlay(out var resultPlayId); + + Assert.False(result); + Assert.Equal(playId, resultPlayId); + } + + [Theory] + [BitAutoData((string?)null)] + [BitAutoData("")] + public void InPlay_WhenPlayIdNullOrEmptyAndDevelopment_ReturnsFalse( + string? playId, + SutProvider sutProvider) + { + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Development); + sutProvider.Sut.PlayId = playId; + + var result = sutProvider.Sut.InPlay(out var resultPlayId); + + Assert.False(result); + Assert.Empty(resultPlayId); + } + + [Theory] + [BitAutoData] + public void PlayId_CanGetAndSet(string playId) + { + var hostEnvironment = Substitute.For(); + var sut = new PlayIdService(hostEnvironment); + + sut.PlayId = playId; + + Assert.Equal(playId, sut.PlayId); + } +} + +[SutProviderCustomize] +public class NeverPlayIdServicesTests +{ + [Fact] + public void InPlay_ReturnsFalse() + { + var sut = new NeverPlayIdServices(); + + var result = sut.InPlay(out var playId); + + Assert.False(result); + Assert.Empty(playId); + } + + [Theory] + [InlineData("test-play-id")] + [InlineData(null)] + public void PlayId_SetterDoesNothing_GetterReturnsNull(string? value) + { + var sut = new NeverPlayIdServices(); + + sut.PlayId = value; + + Assert.Null(sut.PlayId); + } +} + +[SutProviderCustomize] +public class PlayIdSingletonServiceTests +{ + public static IEnumerable SutProvider() + { + var sutProvider = new SutProvider(); + var httpContext = sutProvider.CreateDependency(); + var serviceProvider = sutProvider.CreateDependency(); + var hostEnvironment = sutProvider.CreateDependency(); + var playIdService = new PlayIdService(hostEnvironment); + sutProvider.SetDependency(playIdService); + httpContext.RequestServices.Returns(serviceProvider); + serviceProvider.GetService().Returns(playIdService); + serviceProvider.GetRequiredService().Returns(playIdService); + sutProvider.CreateDependency().HttpContext.Returns(httpContext); + sutProvider.Create(); + return [[sutProvider]]; + } + + private void PrepHttpContext( + SutProvider sutProvider) + { + var httpContext = sutProvider.CreateDependency(); + var serviceProvider = sutProvider.CreateDependency(); + var PlayIdService = sutProvider.CreateDependency(); + httpContext.RequestServices.Returns(serviceProvider); + serviceProvider.GetRequiredService().Returns(PlayIdService); + sutProvider.GetDependency().HttpContext.Returns(httpContext); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void InPlay_WhenNoHttpContext_ReturnsFalse( + SutProvider sutProvider) + { + sutProvider.GetDependency().HttpContext.Returns((HttpContext?)null); + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Development); + + var result = sutProvider.Sut.InPlay(out var playId); + + Assert.False(result); + Assert.Empty(playId); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void InPlay_WhenNotDevelopment_ReturnsFalse( + SutProvider sutProvider, + string playIdValue) + { + var scopedPlayIdService = sutProvider.GetDependency(); + scopedPlayIdService.PlayId = playIdValue; + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Production); + + var result = sutProvider.Sut.InPlay(out var playId); + + Assert.False(result); + Assert.Empty(playId); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void InPlay_WhenDevelopmentAndHttpContextWithPlayId_ReturnsTrue( + SutProvider sutProvider, + string playIdValue) + { + sutProvider.GetDependency().PlayId = playIdValue; + sutProvider.GetDependency().EnvironmentName.Returns(Environments.Development); + + var result = sutProvider.Sut.InPlay(out var playId); + + Assert.True(result); + Assert.Equal(playIdValue, playId); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void PlayId_SetterSetsOnScopedService( + SutProvider sutProvider, + string playIdValue) + { + var scopedPlayIdService = sutProvider.GetDependency(); + + sutProvider.Sut.PlayId = playIdValue; + + Assert.Equal(playIdValue, scopedPlayIdService.PlayId); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void PlayId_WhenNoHttpContext_GetterReturnsNull( + SutProvider sutProvider) + { + sutProvider.GetDependency().HttpContext.Returns((HttpContext?)null); + + var result = sutProvider.Sut.PlayId; + + Assert.Null(result); + } + + [Theory] + [BitMemberAutoData(nameof(SutProvider))] + public void PlayId_WhenNoHttpContext_SetterDoesNotThrow( + SutProvider sutProvider, + string playIdValue) + { + sutProvider.GetDependency().HttpContext.Returns((HttpContext?)null); + + sutProvider.Sut.PlayId = playIdValue; + } +} diff --git a/test/Core.Test/Services/PlayItemServiceTests.cs b/test/Core.Test/Services/PlayItemServiceTests.cs new file mode 100644 index 0000000000..02e815be59 --- /dev/null +++ b/test/Core.Test/Services/PlayItemServiceTests.cs @@ -0,0 +1,143 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class PlayItemServiceTests +{ + [Theory] + [BitAutoData] + public async Task Record_User_WhenInPlay_RecordsPlayItem( + string playId, + User user, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InPlay(out Arg.Any()) + .Returns(x => + { + x[0] = playId; + return true; + }); + + await sutProvider.Sut.Record(user); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(pd => + pd.PlayId == playId && + pd.UserId == user.Id && + pd.OrganizationId == null)); + + sutProvider.GetDependency>() + .Received(1) + .Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString().Contains(user.Id.ToString()) && o.ToString().Contains(playId)), + null, + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task Record_User_WhenNotInPlay_DoesNotRecordPlayItem( + User user, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InPlay(out Arg.Any()) + .Returns(x => + { + x[0] = null; + return false; + }); + + await sutProvider.Sut.Record(user); + + await sutProvider.GetDependency() + .DidNotReceive() + .CreateAsync(Arg.Any()); + + sutProvider.GetDependency>() + .DidNotReceive() + .Log( + LogLevel.Information, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task Record_Organization_WhenInPlay_RecordsPlayItem( + string playId, + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InPlay(out Arg.Any()) + .Returns(x => + { + x[0] = playId; + return true; + }); + + await sutProvider.Sut.Record(organization); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(pd => + pd.PlayId == playId && + pd.OrganizationId == organization.Id && + pd.UserId == null)); + + sutProvider.GetDependency>() + .Received(1) + .Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString().Contains(organization.Id.ToString()) && o.ToString().Contains(playId)), + null, + Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task Record_Organization_WhenNotInPlay_DoesNotRecordPlayItem( + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .InPlay(out Arg.Any()) + .Returns(x => + { + x[0] = null; + return false; + }); + + await sutProvider.Sut.Record(organization); + + await sutProvider.GetDependency() + .DidNotReceive() + .CreateAsync(Arg.Any()); + + sutProvider.GetDependency>() + .DidNotReceive() + .Log( + LogLevel.Information, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } +} diff --git a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs index b92477e73d..aea06f39a8 100644 --- a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs +++ b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs @@ -135,6 +135,43 @@ public class ImportCiphersAsyncCommandTests Assert.Equal("You cannot import items into your personal vault because you are a member of an organization which forbids it.", exception.Message); } + [Theory, BitAutoData] + public async Task ImportIntoIndividualVaultAsync_FavoriteCiphers_PersistsFavoriteInfo( + Guid importingUserId, + List ciphers, + SutProvider sutProvider + ) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(importingUserId) + .Returns(new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Disabled, + [])); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(importingUserId) + .Returns(new List()); + + var folders = new List(); + var folderRelationships = new List>(); + + ciphers.ForEach(c => + { + c.UserId = importingUserId; + c.Favorite = true; + }); + + await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(importingUserId, Arg.Is>(ciphers => ciphers.All(c => c.Favorites == $"{{\"{importingUserId.ToString().ToUpperInvariant()}\":true}}")), Arg.Any>()); + } + [Theory, BitAutoData] public async Task ImportIntoOrganizationalVaultAsync_Success( Organization organization, diff --git a/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs b/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs index c34afc42bd..b4b1ecbc79 100644 --- a/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs +++ b/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Repositories; using Bit.Core.Tools.SendFeatures.Queries; @@ -42,12 +43,12 @@ public class SendAuthenticationQueryTests } [Theory] - [MemberData(nameof(EmailParsingTestCases))] - public async Task GetAuthenticationMethod_WithEmails_ParsesEmailsCorrectly(string emailString, string[] expectedEmails) + [MemberData(nameof(EmailHashesParsingTestCases))] + public async Task GetAuthenticationMethod_WithEmailHashes_ParsesEmailHashesCorrectly(string emailHashString, string[] expectedEmailHashes) { // Arrange var sendId = Guid.NewGuid(); - var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null); + var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: emailHashString, password: null, AuthType.Email); _sendRepository.GetByIdAsync(sendId).Returns(send); // Act @@ -55,15 +56,15 @@ public class SendAuthenticationQueryTests // Assert var emailOtp = Assert.IsType(result); - Assert.Equal(expectedEmails, emailOtp.Emails); + Assert.Equal(expectedEmailHashes, emailOtp.EmailHashes); } [Fact] - public async Task GetAuthenticationMethod_WithBothEmailsAndPassword_ReturnsEmailOtp() + public async Task GetAuthenticationMethod_WithBothEmailHashesAndPassword_ReturnsEmailOtp() { // Arrange var sendId = Guid.NewGuid(); - var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword"); + var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: "hashedemail", password: "hashedpassword", AuthType.Email); _sendRepository.GetByIdAsync(sendId).Returns(send); // Act @@ -78,7 +79,7 @@ public class SendAuthenticationQueryTests { // Arrange var sendId = Guid.NewGuid(); - var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null); + var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: null, password: null, AuthType.None); _sendRepository.GetByIdAsync(sendId).Returns(send); // Act @@ -105,31 +106,218 @@ public class SendAuthenticationQueryTests public static IEnumerable AuthenticationMethodTestCases() { yield return new object[] { null, typeof(NeverAuthenticate) }; - yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emails: null, password: null), typeof(NeverAuthenticate) }; - yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emails: null, password: null), typeof(NeverAuthenticate) }; - yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: null), typeof(EmailOtp) }; - yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: "hashedpassword"), typeof(ResourcePassword) }; - yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null), typeof(NotAuthenticated) }; + yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emailHashes: null, password: null, AuthType.None), typeof(NeverAuthenticate) }; + yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emailHashes: null, password: null, AuthType.None), typeof(NeverAuthenticate) }; + yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: "hashedemail", password: null, AuthType.Email), typeof(EmailOtp) }; + yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: null, password: "hashedpassword", AuthType.Password), typeof(ResourcePassword) }; + yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: null, password: null, AuthType.None), typeof(NotAuthenticated) }; } - public static IEnumerable EmailParsingTestCases() + [Fact] + public async Task GetAuthenticationMethod_WithDisabledSend_ReturnsNeverAuthenticate() { - yield return new object[] { "test@example.com", new[] { "test@example.com" } }; - yield return new object[] { "test1@example.com,test2@example.com", new[] { "test1@example.com", "test2@example.com" } }; - yield return new object[] { " test@example.com , other@example.com ", new[] { "test@example.com", "other@example.com" } }; - yield return new object[] { "test@example.com,,other@example.com", new[] { "test@example.com", "other@example.com" } }; - yield return new object[] { " , test@example.com, ,other@example.com, ", new[] { "test@example.com", "other@example.com" } }; + // Arrange + var sendId = Guid.NewGuid(); + var send = new Send + { + Id = sendId, + AccessCount = 0, + MaxAccessCount = 10, + EmailHashes = "hashedemail", + Password = null, + AuthType = AuthType.Email, + Disabled = true, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null + }; + _sendRepository.GetByIdAsync(sendId).Returns(send); + + // Act + var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId); + + // Assert + Assert.IsType(result); } - private static Send CreateSend(int accessCount, int? maxAccessCount, string? emails, string? password) + [Fact] + public async Task GetAuthenticationMethod_WithExpiredSend_ReturnsNeverAuthenticate() + { + // Arrange + var sendId = Guid.NewGuid(); + var send = new Send + { + Id = sendId, + AccessCount = 0, + MaxAccessCount = 10, + EmailHashes = "hashedemail", + Password = null, + AuthType = AuthType.Email, + Disabled = false, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = DateTime.UtcNow.AddDays(-1) // Expired yesterday + }; + _sendRepository.GetByIdAsync(sendId).Returns(send); + + // Act + var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task GetAuthenticationMethod_WithDeletionDatePassed_ReturnsNeverAuthenticate() + { + // Arrange + var sendId = Guid.NewGuid(); + var send = new Send + { + Id = sendId, + AccessCount = 0, + MaxAccessCount = 10, + EmailHashes = "hashedemail", + Password = null, + AuthType = AuthType.Email, + Disabled = false, + DeletionDate = DateTime.UtcNow.AddDays(-1), // Should have been deleted yesterday + ExpirationDate = null + }; + _sendRepository.GetByIdAsync(sendId).Returns(send); + + // Act + var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task GetAuthenticationMethod_WithDeletionDateEqualToNow_ReturnsNeverAuthenticate() + { + // Arrange + var sendId = Guid.NewGuid(); + var now = DateTime.UtcNow; + var send = new Send + { + Id = sendId, + AccessCount = 0, + MaxAccessCount = 10, + EmailHashes = "hashedemail", + Password = null, + AuthType = AuthType.Email, + Disabled = false, + DeletionDate = now, // DeletionDate <= DateTime.UtcNow + ExpirationDate = null + }; + _sendRepository.GetByIdAsync(sendId).Returns(send); + + // Act + var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task GetAuthenticationMethod_WithAccessCountEqualToMaxAccessCount_ReturnsNeverAuthenticate() + { + // Arrange + var sendId = Guid.NewGuid(); + var send = new Send + { + Id = sendId, + AccessCount = 5, + MaxAccessCount = 5, + EmailHashes = "hashedemail", + Password = null, + AuthType = AuthType.Email, + Disabled = false, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null + }; + _sendRepository.GetByIdAsync(sendId).Returns(send); + + // Act + var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task GetAuthenticationMethod_WithNullMaxAccessCount_DoesNotRestrictAccess() + { + // Arrange + var sendId = Guid.NewGuid(); + var send = new Send + { + Id = sendId, + AccessCount = 1000, + MaxAccessCount = null, // No limit + EmailHashes = "hashedemail", + Password = null, + AuthType = AuthType.Email, + Disabled = false, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null + }; + _sendRepository.GetByIdAsync(sendId).Returns(send); + + // Act + var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId); + + // Assert + Assert.IsType(result); + } + + [Fact] + public async Task GetAuthenticationMethod_WithNullExpirationDate_DoesNotExpire() + { + // Arrange + var sendId = Guid.NewGuid(); + var send = new Send + { + Id = sendId, + AccessCount = 0, + MaxAccessCount = 10, + EmailHashes = "hashedemail", + Password = null, + AuthType = AuthType.Email, + Disabled = false, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null // No expiration + }; + _sendRepository.GetByIdAsync(sendId).Returns(send); + + // Act + var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId); + + // Assert + Assert.IsType(result); + } + + public static IEnumerable EmailHashesParsingTestCases() + { + yield return new object[] { "hash1", new[] { "hash1" } }; + yield return new object[] { "hash1,hash2", new[] { "hash1", "hash2" } }; + yield return new object[] { " hash1 , hash2 ", new[] { "hash1", "hash2" } }; + yield return new object[] { "hash1,,hash2", new[] { "hash1", "hash2" } }; + yield return new object[] { " , hash1, ,hash2, ", new[] { "hash1", "hash2" } }; + } + + private static Send CreateSend(int accessCount, int? maxAccessCount, string? emailHashes, string? password, AuthType? authType) { return new Send { Id = Guid.NewGuid(), AccessCount = accessCount, MaxAccessCount = maxAccessCount, - Emails = emails, - Password = password + EmailHashes = emailHashes, + Password = password, + AuthType = authType, + Disabled = false, + DeletionDate = DateTime.UtcNow.AddDays(7), + ExpirationDate = null }; } } diff --git a/test/Core.Test/Tools/Services/SendOwnerQueryTests.cs b/test/Core.Test/Tools/Services/SendOwnerQueryTests.cs index 9a2f942eb8..71ed27f2ac 100644 --- a/test/Core.Test/Tools/Services/SendOwnerQueryTests.cs +++ b/test/Core.Test/Tools/Services/SendOwnerQueryTests.cs @@ -12,7 +12,6 @@ namespace Bit.Core.Test.Tools.Services; public class SendOwnerQueryTests { private readonly ISendRepository _sendRepository; - private readonly IFeatureService _featureService; private readonly IUserService _userService; private readonly SendOwnerQuery _sendOwnerQuery; private readonly Guid _currentUserId = Guid.NewGuid(); @@ -21,11 +20,10 @@ public class SendOwnerQueryTests public SendOwnerQueryTests() { _sendRepository = Substitute.For(); - _featureService = Substitute.For(); _userService = Substitute.For(); _user = new ClaimsPrincipal(); _userService.GetProperUserId(_user).Returns(_currentUserId); - _sendOwnerQuery = new SendOwnerQuery(_sendRepository, _featureService, _userService); + _sendOwnerQuery = new SendOwnerQuery(_sendRepository, _userService); } [Fact] @@ -84,7 +82,7 @@ public class SendOwnerQueryTests } [Fact] - public async Task GetOwned_WithFeatureFlagEnabled_ReturnsAllSends() + public async Task GetOwned_ReturnsAllSendsIncludingEmailOTP() { // Arrange var sends = new List @@ -94,7 +92,6 @@ public class SendOwnerQueryTests CreateSend(Guid.NewGuid(), _currentUserId, emails: "other@example.com") }; _sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends); - _featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true); // Act var result = await _sendOwnerQuery.GetOwned(_user); @@ -105,28 +102,6 @@ public class SendOwnerQueryTests Assert.Contains(sends[1], result); Assert.Contains(sends[2], result); await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId); - _featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends); - } - - [Fact] - public async Task GetOwned_WithFeatureFlagDisabled_FiltersOutEmailOtpSends() - { - // Arrange - var sendWithoutEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: null); - var sendWithEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: "test@example.com"); - var sends = new List { sendWithoutEmails, sendWithEmails }; - _sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends); - _featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(false); - - // Act - var result = await _sendOwnerQuery.GetOwned(_user); - - // Assert - Assert.Single(result); - Assert.Contains(sendWithoutEmails, result); - Assert.DoesNotContain(sendWithEmails, result); - await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId); - _featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends); } [Fact] @@ -147,7 +122,6 @@ public class SendOwnerQueryTests // Arrange var emptySends = new List(); _sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(emptySends); - _featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true); // Act var result = await _sendOwnerQuery.GetOwned(_user); diff --git a/test/Core.Test/Tools/Services/SendValidationServiceTests.cs b/test/Core.Test/Tools/Services/SendValidationServiceTests.cs new file mode 100644 index 0000000000..8adce1a29f --- /dev/null +++ b/test/Core.Test/Tools/Services/SendValidationServiceTests.cs @@ -0,0 +1,120 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Pricing.Premium; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Tools.Services; + +[SutProviderCustomize] +public class SendValidationServiceTests +{ + [Theory, BitAutoData] + public async Task StorageRemainingForSendAsync_OrgGrantedPremiumUser_UsesPricingService( + SutProvider sutProvider, + Send send, + User user) + { + // Arrange + send.UserId = user.Id; + send.OrganizationId = null; + send.Type = SendType.File; + user.Premium = false; + user.Storage = 1024L * 1024L * 1024L; // 1 GB used + user.EmailVerified = true; + + sutProvider.GetDependency().SelfHosted = false; + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + sutProvider.GetDependency().CanAccessPremium(user).Returns(true); + + var premiumPlan = new Plan + { + Storage = new Purchasable { Provided = 5 } + }; + sutProvider.GetDependency().GetAvailablePremiumPlan().Returns(premiumPlan); + + // Act + var result = await sutProvider.Sut.StorageRemainingForSendAsync(send); + + // Assert + await sutProvider.GetDependency().Received(1).GetAvailablePremiumPlan(); + Assert.True(result > 0); + } + + [Theory, BitAutoData] + public async Task StorageRemainingForSendAsync_IndividualPremium_DoesNotCallPricingService( + SutProvider sutProvider, + Send send, + User user) + { + // Arrange + send.UserId = user.Id; + send.OrganizationId = null; + send.Type = SendType.File; + user.Premium = true; + user.MaxStorageGb = 10; + user.EmailVerified = true; + + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + sutProvider.GetDependency().CanAccessPremium(user).Returns(true); + + // Act + var result = await sutProvider.Sut.StorageRemainingForSendAsync(send); + + // Assert - should NOT call pricing service for individual premium users + await sutProvider.GetDependency().DidNotReceive().GetAvailablePremiumPlan(); + } + + [Theory, BitAutoData] + public async Task StorageRemainingForSendAsync_SelfHosted_DoesNotCallPricingService( + SutProvider sutProvider, + Send send, + User user) + { + // Arrange + send.UserId = user.Id; + send.OrganizationId = null; + send.Type = SendType.File; + user.Premium = false; + user.EmailVerified = true; + + sutProvider.GetDependency().SelfHosted = true; + sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user); + sutProvider.GetDependency().CanAccessPremium(user).Returns(true); + + // Act + var result = await sutProvider.Sut.StorageRemainingForSendAsync(send); + + // Assert - should NOT call pricing service for self-hosted + await sutProvider.GetDependency().DidNotReceive().GetAvailablePremiumPlan(); + } + + [Theory, BitAutoData] + public async Task StorageRemainingForSendAsync_OrgSend_DoesNotCallPricingService( + SutProvider sutProvider, + Send send, + Organization org) + { + // Arrange + send.UserId = null; + send.OrganizationId = org.Id; + send.Type = SendType.File; + org.MaxStorageGb = 100; + + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + + // Act + var result = await sutProvider.Sut.StorageRemainingForSendAsync(send); + + // Assert - should NOT call pricing service for org sends + await sutProvider.GetDependency().DidNotReceive().GetAvailablePremiumPlan(); + } +} diff --git a/test/Core.Test/Utilities/DomainNameAttributeTests.cs b/test/Core.Test/Utilities/DomainNameAttributeTests.cs new file mode 100644 index 0000000000..3f3190c9a1 --- /dev/null +++ b/test/Core.Test/Utilities/DomainNameAttributeTests.cs @@ -0,0 +1,84 @@ +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class DomainNameValidatorAttributeTests +{ + [Theory] + [InlineData("example.com")] // basic domain + [InlineData("sub.example.com")] // subdomain + [InlineData("sub.sub2.example.com")] // multiple subdomains + [InlineData("example-dash.com")] // domain with dash + [InlineData("123example.com")] // domain starting with number + [InlineData("example123.com")] // domain with numbers + [InlineData("e.com")] // short domain + [InlineData("very-long-subdomain-name.example.com")] // long subdomain + [InlineData("wörldé.com")] // unicode domain (IDN) + public void IsValid_ReturnsTrueWhenValid(string domainName) + { + var sut = new DomainNameValidatorAttribute(); + + var actual = sut.IsValid(domainName); + + Assert.True(actual); + } + + [Theory] + [InlineData("")] // XSS attempt + [InlineData("example.com
+ - +
-

+

© 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 + bitwarden.com | + Learn why we include this