diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f42f226153..d7814849c6 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "swashbuckle.aspnetcore.cli": { - "version": "7.2.0", + "version": "7.3.2", "commands": ["swagger"] }, "dotnet-ef": { diff --git a/.devcontainer/bitwarden_common/docker-compose.yml b/.devcontainer/bitwarden_common/docker-compose.yml index 2f3a62877e..fbfca10d21 100644 --- a/.devcontainer/bitwarden_common/docker-compose.yml +++ b/.devcontainer/bitwarden_common/docker-compose.yml @@ -3,6 +3,9 @@ services: image: mcr.microsoft.com/devcontainers/dotnet:8.0 volumes: - ../../:/workspace:cached + env_file: + - path: ../../dev/.env + required: false # Overrides default command so things don't shut down after the process ends. command: sleep infinity diff --git a/.devcontainer/internal_dev/postCreateCommand.sh b/.devcontainer/internal_dev/postCreateCommand.sh index 071ffc0b29..3fd278be26 100755 --- a/.devcontainer/internal_dev/postCreateCommand.sh +++ b/.devcontainer/internal_dev/postCreateCommand.sh @@ -1,17 +1,42 @@ #!/usr/bin/env bash -export DEV_DIR=/workspace/dev +export REPO_ROOT="$(git rev-parse --show-toplevel)" export CONTAINER_CONFIG=/workspace/.devcontainer/internal_dev + git config --global --add safe.directory /workspace -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 - jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" | - .globalSettings.installation.key = \"$INSTALLATION_KEY\"" \ - secrets.json.example >secrets.json # create/overwrite secrets.json - popd >/dev/null || exit +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 } remove_comments() { @@ -26,51 +51,70 @@ remove_comments() { configure_other_vars() { pushd ./dev >/dev/null || exit - cp secrets.json .secrets.json.tmp + cp "$REPO_ROOT/dev/secrets.json" "$REPO_ROOT/dev/.secrets.json.tmp" # set DB_PASSWORD equal to .services.mssql.environment.MSSQL_SA_PASSWORD, accounting for quotes - DB_PASSWORD="$(grep -oP 'MSSQL_SA_PASSWORD=["'"'"']?\K[^"'"'"'\s]+' $DEV_DIR/.env)" + DB_PASSWORD="$(grep -oP 'MSSQL_SA_PASSWORD=["'"'"']?\K[^"'"'"'\s]+' $REPO_ROOT/dev/.env)" SQL_CONNECTION_STRING="Server=localhost;Database=vault_dev;User Id=SA;Password=$DB_PASSWORD;Encrypt=True;TrustServerCertificate=True" jq \ ".globalSettings.sqlServer.connectionString = \"$SQL_CONNECTION_STRING\" | .globalSettings.postgreSql.connectionString = \"Host=localhost;Username=postgres;Password=$DB_PASSWORD;Database=vault_dev;Include Error Detail=true\" | .globalSettings.mySql.connectionString = \"server=localhost;uid=root;pwd=$DB_PASSWORD;database=vault_dev\"" \ .secrets.json.tmp >secrets.json - rm .secrets.json.tmp + rm "$REPO_ROOT/dev/.secrets.json.tmp" popd >/dev/null || exit } one_time_setup() { - read -r -p \ - "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 - echo "Running one-time setup script..." - sleep 1 - read -r -p \ - "Place the secrets.json and dev.pfx files from our shared Collection in the ./dev directory. + if [[ ! -f "$REPO_ROOT/dev/dev.pfx" ]]; then + # We do not have the cert file + if [[ ! -z "${DEV_CERT_CONTENTS}" ]]; then + # Make file for them + echo "Making $REPO_ROOT/dev/dev.pfx file for you based on DEV_CERT_CONTENTS environment variable." + # Assume content is base64 encoded + echo "$DEV_CERT_CONTENTS" | base64 -d > "$REPO_ROOT/dev/dev.pfx" + else + if [[ $allow_interactive -eq 1 ]]; then + read -r -p \ + "Place the dev.pfx files from our shared Collection in the $REPO_ROOT/dev directory. Press to continue." - remove_comments ./dev/secrets.json + fi + fi + fi + + if [[ -f "$REPO_ROOT/dev/dev.pfx" ]]; then + dotnet tool install dotnet-certificate-tool -g >/dev/null + cert_password="$(get_option "DEV_CERT_PASSWORD" "Paste the \"Licensing Certificate - Dev\" password: " "" 1)" + certificate-tool add --file "$REPO_ROOT/dev/dev.pfx" --password "$cert_password" + else + echo "You don't have a $REPO_ROOT/dev/dev.pfx file setup." >/dev/stderr + fi + + do_secrets_json_setup="$(get_option "SETUP_SECRETS_JSON" "Would you like us to setup your secrets.json file for you? [y/N] " "n")" + if [[ "$do_secrets_json_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then + remove_comments "$REPO_ROOT/dev/secrets.json" configure_other_vars + # setup_secrets needs to be ran from the dev folder + pushd "$REPO_ROOT/dev" >/dev/null || exit + echo "Injecting dotnet secrets..." + pwsh "$REPO_ROOT/dev/setup_secrets.ps1" || true + popd >/dev/null || exit + fi + + do_azurite_setup="$(get_option "SETUP_AZURITE" "Would you like us to setup your azurite environment? [y/N] " "n")" + if [[ "$do_azurite_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then echo "Installing Az module. This will take ~a minute..." pwsh -Command "Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force" - pwsh ./dev/setup_azurite.ps1 - - dotnet tool install dotnet-certificate-tool -g >/dev/null - - read -r -s -p "Paste the \"Licensing Certificate - Dev\" password: " CERT_PASSWORD - echo - pushd ./dev >/dev/null || exit - certificate-tool add --file ./dev.pfx --password "$CERT_PASSWORD" - echo "Injecting dotnet secrets..." - pwsh ./setup_secrets.ps1 || true - popd >/dev/null || exit + pwsh "$REPO_ROOT/dev/setup_azurite.ps1" + 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 echo "Running migrations..." sleep 5 # wait for DB container to start - dotnet run --project ./util/MsSqlMigratorUtility "$SQL_CONNECTION_STRING" + dotnet run --project "$REPO_ROOT/util/MsSqlMigratorUtility" "$SQL_CONNECTION_STRING" fi - read -r -p "Would you like to install the Stripe CLI? [y/N] " stripe_response + + stripe_response="$(get_option "INSTALL_STRIPE_CLI" "Would you like to install the Stripe CLI? [y/N] " "n")" if [[ "$stripe_response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then install_stripe_cli fi @@ -88,11 +132,4 @@ install_stripe_cli() { sudo apt install -y stripe } -# 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 \ No newline at end of file +one_time_setup diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5399bed391..88cfc71256 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,7 +14,7 @@ .github/workflows/publish.yml @bitwarden/dept-bre ## These are shared workflows ## -.github/workflows/_move_finalization_db_scripts.yml +.github/workflows/_move_edd_db_scripts.yml .github/workflows/release.yml # Database Operations for database changes @@ -33,6 +33,9 @@ util/SqliteMigrations/** @bitwarden/dept-dbops # Shared util projects util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev +# UIF +src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project + # Auth team **/Auth @bitwarden/team-auth-dev bitwarden_license/src/Sso @bitwarden/team-auth-dev @@ -47,11 +50,7 @@ src/Core/IdentityServer @bitwarden/team-auth-dev **/Tools @bitwarden/team-tools-dev # Dirt (Data Insights & Reporting) team -src/Api/Controllers/Dirt @bitwarden/team-data-insights-and-reporting-dev -src/Core/Dirt @bitwarden/team-data-insights-and-reporting-dev -src/Infrastructure.Dapper/Dirt @bitwarden/team-data-insights-and-reporting-dev -test/Api.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev -test/Core.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev +**/Dirt @bitwarden/team-data-insights-and-reporting-dev # Vault team **/Vault @bitwarden/team-vault-dev diff --git a/.github/ISSUE_TEMPLATE/bw-unified.yml b/.github/ISSUE_TEMPLATE/bw-unified.yml index c1284f1839..240b1faa72 100644 --- a/.github/ISSUE_TEMPLATE/bw-unified.yml +++ b/.github/ISSUE_TEMPLATE/bw-unified.yml @@ -1,4 +1,3 @@ -name: Bitwarden Unified Bug Report name: Bitwarden Unified Deployment Bug Report description: File a bug report labels: [bug, bw-unified-deploy] diff --git a/.github/renovate.json5 b/.github/renovate.json5 index ac34903c1b..5c01832c06 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -9,19 +9,6 @@ "nuget", ], packageRules: [ - { - // Group all release-related workflows for GitHub Actions together for BRE. - groupName: "github-action", - matchManagers: ["github-actions"], - matchFileNames: [ - ".github/workflows/publish.yml", - ".github/workflows/release.yml", - ".github/workflows/repository-management.yml" - ], - commitMessagePrefix: "[deps] BRE:", - reviewers: ["team:dept-bre"], - addLabels: ["hold"], - }, { groupName: "dockerfile minor", matchManagers: ["dockerfile"], @@ -36,6 +23,7 @@ groupName: "github-action minor", matchManagers: ["github-actions"], matchUpdateTypes: ["minor"], + addLabels: ["hold"], }, { // For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates. @@ -96,7 +84,6 @@ "Serilog.AspNetCore", "Serilog.Extensions.Logging", "Serilog.Extensions.Logging.File", - "Serilog.Sinks.AzureCosmosDB", "Serilog.Sinks.SyslogMessages", "Stripe.net", "Swashbuckle.AspNetCore", @@ -134,8 +121,8 @@ reviewers: ["team:dept-dbops"], }, { - matchPackageNames: ["CommandDotNet", "YamlDotNet"], - description: "DevOps owned dependencies", + matchPackageNames: ["YamlDotNet"], + description: "BRE owned dependencies", commitMessagePrefix: "[deps] BRE:", reviewers: ["team:dept-bre"], }, diff --git a/.github/workflows/_move_finalization_db_scripts.yml b/.github/workflows/_move_edd_db_scripts.yml similarity index 54% rename from .github/workflows/_move_finalization_db_scripts.yml rename to .github/workflows/_move_edd_db_scripts.yml index d897875394..98fe4f1f05 100644 --- a/.github/workflows/_move_finalization_db_scripts.yml +++ b/.github/workflows/_move_edd_db_scripts.yml @@ -1,5 +1,5 @@ -name: _move_finalization_db_scripts -run-name: Move finalization database scripts +name: _move_edd_db_scripts +run-name: Move EDD database scripts on: workflow_call: @@ -12,14 +12,20 @@ jobs: setup: name: Setup runs-on: ubuntu-22.04 + permissions: + contents: read + id-token: write outputs: migration_filename_prefix: ${{ steps.prefix.outputs.prefix }} - copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }} + copy_edd_scripts: ${{ steps.check-script-existence.outputs.copy_edd_scripts }} + steps: - name: Log in to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -28,6 +34,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Check out branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -37,22 +46,27 @@ jobs: id: prefix run: echo "prefix=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT - - name: Check if any files in DB finalization directory - id: check-finalization-scripts-existence + - name: Check if any files in DB transition or finalization directories + id: check-script-existence run: | - if [ -f util/Migrator/DbScripts_finalization/* ]; then - echo "copy_finalization_scripts=true" >> $GITHUB_OUTPUT + if [ -f util/Migrator/DbScripts_transition/* -o -f util/Migrator/DbScripts_finalization/* ]; then + echo "copy_edd_scripts=true" >> $GITHUB_OUTPUT else - echo "copy_finalization_scripts=false" >> $GITHUB_OUTPUT + echo "copy_edd_scripts=false" >> $GITHUB_OUTPUT fi - move-finalization-db-scripts: - name: Move finalization database scripts + move-scripts: + name: Move scripts runs-on: ubuntu-22.04 needs: setup - if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }} + permissions: + contents: write + pull-requests: write + id-token: write + actions: read + if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }} steps: - - name: Checkout + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 @@ -61,41 +75,70 @@ jobs: id: branch_name env: PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }} - run: echo "branch_name=move_finalization_db_scripts_$PREFIX" >> $GITHUB_OUTPUT + run: echo "branch_name=move_edd_db_scripts_$PREFIX" >> $GITHUB_OUTPUT - name: "Create branch" env: BRANCH: ${{ steps.branch_name.outputs.branch_name }} run: git switch -c $BRANCH - - name: Move DbScripts_finalization + - name: Move scripts and finalization database schema id: move-files env: PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }} run: | - src_dir="util/Migrator/DbScripts_finalization" + # scripts + moved_files="Migration scripts moved:\n\n" + + src_dirs="util/Migrator/DbScripts_transition,util/Migrator/DbScripts_finalization" dest_dir="util/Migrator/DbScripts" i=0 - moved_files="" - for file in "$src_dir"/*; do - filenumber=$(printf "%02d" $i) + for src_dir in ${src_dirs//,/ }; do + for file in "$src_dir"/*; do + filenumber=$(printf "%02d" $i) - filename=$(basename "$file") - new_filename="${PREFIX}_${filenumber}_${filename}" - dest_file="$dest_dir/$new_filename" + filename=$(basename "$file") + new_filename="${PREFIX}_${filenumber}_${filename}" + dest_file="$dest_dir/$new_filename" - mv "$file" "$dest_file" - moved_files="$moved_files \n $filename -> $new_filename" + # Replace any finalization references due to the move + sed -i -e 's/dbo_finalization/dbo/g' "$file" - i=$((i+1)) + mv "$file" "$dest_file" + moved_files="$moved_files \n $filename -> $new_filename" + + i=$((i+1)) + done done + + # schema + moved_files="$moved_files\n\nFinalization scripts moved:\n\n" + + src_dir="src/Sql/dbo_finalization" + dest_dir="src/Sql/dbo" + + # sync finalization schema back to dbo, maintaining structure + rsync -r "$src_dir/" "$dest_dir/" + rm -rf $src_dir/* + + # Replace any finalization references due to the move + find ./src/Sql/dbo -name "*.sql" -type f -exec sed -i \ + -e 's/\[dbo_finalization\]/[dbo]/g' \ + -e 's/dbo_finalization\./dbo./g' {} + + + for file in "$src_dir"/**/*; do + moved_files="$moved_files \n $file" + done + echo "moved_files=$moved_files" >> $GITHUB_OUTPUT - - name: Log in to Azure - production subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -106,6 +149,9 @@ jobs: github-gpg-private-key-passphrase, devops-alerts-slack-webhook-url" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Import GPG keys uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0 with: @@ -121,7 +167,7 @@ jobs: git config --local user.name "bitwarden-devops-bot" if [ -n "$(git status --porcelain)" ]; then git add . - git commit -m "Move DbScripts_finalization to DbScripts" -a + git commit -m "Move EDD database scripts" -a git push -u origin ${{ steps.branch_name.outputs.branch_name }} echo "pr_needed=true" >> $GITHUB_OUTPUT else @@ -137,16 +183,16 @@ jobs: BRANCH: ${{ steps.branch_name.outputs.branch_name }} GH_TOKEN: ${{ github.token }} MOVED_FILES: ${{ steps.move-files.outputs.moved_files }} - TITLE: "Move finalization database scripts" + TITLE: "Move EDD database scripts" run: | PR_URL=$(gh pr create --title "$TITLE" \ --base "main" \ --head "$BRANCH" \ --label "automated pr" \ --body " - ## Automated movement of DbScripts_finalization to DbScripts + Automated movement of EDD database scripts. - ## Files moved: + Files moved: $(echo -e "$MOVED_FILES") ") echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT @@ -157,5 +203,5 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }} with: - message: "Created PR for moving DbScripts_finalization to DbScripts: ${{ steps.create-pr.outputs.pr_url }}" + message: "Created PR for moving EDD database scripts: ${{ steps.create-pr.outputs.pr_url }}" status: ${{ job.status }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 19eea71b6a..54c31ee6ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,9 @@ on: workflow_call: inputs: {} +permissions: + contents: read + env: _AZ_REGISTRY: "bitwardenprod.azurecr.io" _GITHUB_PR_REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }} @@ -19,7 +22,7 @@ env: jobs: lint: name: Lint - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -33,115 +36,15 @@ jobs: run: dotnet format --verify-no-changes build-artifacts: - name: Build artifacts - runs-on: ubuntu-22.04 + name: Build Docker images + runs-on: ubuntu-24.04 needs: - lint outputs: has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} - strategy: - fail-fast: false - matrix: - include: - - project_name: Admin - base_path: ./src - node: true - - project_name: Api - base_path: ./src - - project_name: Billing - base_path: ./src - - project_name: Events - base_path: ./src - - project_name: EventsProcessor - base_path: ./src - - project_name: Icons - base_path: ./src - - project_name: Identity - base_path: ./src - - project_name: MsSqlMigratorUtility - base_path: ./util - dotnet: true - - project_name: Notifications - base_path: ./src - - project_name: Scim - base_path: ./bitwarden_license/src - dotnet: true - - project_name: Server - base_path: ./util - - project_name: Setup - base_path: ./util - - project_name: Sso - base_path: ./bitwarden_license/src - node: true - steps: - - name: Check secrets - id: check-secrets - env: - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - run: | - has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }} - echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT - - - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: "npm" - cache-dependency-path: "**/package-lock.json" - node-version: "16" - - - name: Print environment - run: | - whoami - dotnet --info - node --version - npm --version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Build node - if: ${{ matrix.node }} - working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} - run: | - npm ci - npm run build - - - name: Publish project - working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} - run: | - echo "Publish" - dotnet publish -c "Release" -o obj/build-output/publish - - cd obj/build-output/publish - zip -r ${{ matrix.project_name }}.zip . - mv ${{ matrix.project_name }}.zip ../../../ - - pwd - ls -atlh ../../../ - - - name: Upload project artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: ${{ matrix.project_name }}.zip - path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip - if-no-files-found: error - - build-docker: - name: Build Docker images - runs-on: ubuntu-22.04 permissions: security-events: write id-token: write - needs: - - build-artifacts - if: ${{ needs.build-artifacts.outputs.has_secrets == 'true' }} strategy: fail-fast: false matrix: @@ -149,6 +52,7 @@ jobs: - project_name: Admin base_path: ./src dotnet: true + node: true - project_name: Api base_path: ./src dotnet: true @@ -182,9 +86,6 @@ jobs: - project_name: Scim base_path: ./bitwarden_license/src dotnet: true - - project_name: Server - base_path: ./util - dotnet: true - project_name: Setup base_path: ./util dotnet: true @@ -192,6 +93,12 @@ jobs: base_path: ./bitwarden_license/src dotnet: true steps: + - name: Check secrets + id: check-secrets + run: | + has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} + echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -203,27 +110,78 @@ jobs: id: publish-branch-check run: | IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES - if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then echo "is_publish_branch=true" >> $GITHUB_ENV else echo "is_publish_branch=false" >> $GITHUB_ENV fi - ########## ACRs ########## - - name: Log in to Azure - production subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Set up .NET + uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 + + - name: Set up Node + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 with: - creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + cache: "npm" + cache-dependency-path: "**/package-lock.json" + node-version: "16" + + - name: Print environment + run: | + whoami + dotnet --info + node --version + npm --version + echo "GitHub ref: $GITHUB_REF" + echo "GitHub event: $GITHUB_EVENT" + + - name: Build node + if: ${{ matrix.node }} + working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} + run: | + npm ci + npm run build + + - name: Publish project + working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }} + if: ${{ matrix.dotnet }} + run: | + echo "Publish" + dotnet publish -c "Release" -o obj/build-output/publish + + cd obj/build-output/publish + zip -r ${{ matrix.project_name }}.zip . + mv ${{ matrix.project_name }}.zip ../../../ + + pwd + ls -atlh ../../../ + + - name: Upload project artifact + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + if: ${{ matrix.dotnet }} + with: + name: ${{ matrix.project_name }}.zip + path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip + if-no-files-found: error + + ########## Set up Docker ########## + - name: Set up QEMU emulators + uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + ########## ACRs ########## + - 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 ACR - production subscription run: az acr login -n bitwardenprod - - name: Log in to Azure - CI subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 - with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - name: Retrieve GitHub PAT secrets id: retrieve-secret-pat uses: bitwarden/gh-actions/get-keyvault-secrets@main @@ -277,26 +235,16 @@ jobs: fi echo "tags=$TAGS" >> $GITHUB_OUTPUT - - name: Get build artifact - if: ${{ matrix.dotnet }} - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 - with: - name: ${{ matrix.project_name }}.zip - - - name: Set up build artifact - if: ${{ matrix.dotnet }} - run: | - mkdir -p ${{ matrix.base_path}}/${{ matrix.project_name }}/obj/build-output/publish - unzip ${{ matrix.project_name }}.zip \ - -d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish - - name: Build Docker image - id: build-docker + id: build-artifacts uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 with: - context: ${{ matrix.base_path }}/${{ matrix.project_name }} + context: . file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile - platforms: linux/amd64 + platforms: | + linux/amd64, + linux/arm/v7, + linux/arm64 push: true tags: ${{ steps.image-tags.outputs.tags }} secrets: | @@ -309,7 +257,7 @@ jobs: - name: Sign image with Cosign if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' env: - DIGEST: ${{ steps.build-docker.outputs.digest }} + DIGEST: ${{ steps.build-artifacts.outputs.digest }} TAGS: ${{ steps.image-tags.outputs.tags }} run: | IFS="," read -a tags <<< "${TAGS}" @@ -327,17 +275,23 @@ jobs: fail-build: false output-format: sarif - - name: Upload Grype results to GitHub - uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 - with: - sarif_file: ${{ steps.container-scan.outputs.sarif }} - sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} - ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} +# - name: Upload Grype results to GitHub +# uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 +# with: +# sarif_file: ${{ steps.container-scan.outputs.sarif }} +# sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} +# ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main upload: name: Upload - runs-on: ubuntu-22.04 - needs: build-docker + runs-on: ubuntu-24.04 + needs: build-artifacts + permissions: + id-token: write + actions: read steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -347,10 +301,12 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 - - name: Log in to Azure - production subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to ACR - production subscription run: az acr login -n $_AZ_REGISTRY --only-show-errors @@ -377,9 +333,9 @@ jobs: # Run setup docker run -i --rm --name setup -v $STUB_OUTPUT/US:/bitwarden $SETUP_IMAGE \ - dotnet Setup.dll -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region US + /app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region US docker run -i --rm --name setup -v $STUB_OUTPUT/EU:/bitwarden $SETUP_IMAGE \ - dotnet Setup.dll -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU + /app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU sudo chown -R $(whoami):$(whoami) $STUB_OUTPUT @@ -397,13 +353,8 @@ jobs: cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../.. cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../.. - - name: Make Docker stub checksums - if: | - github.event_name != 'pull_request' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - run: | - sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt - sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main - name: Upload Docker stub US artifact if: | @@ -425,26 +376,6 @@ jobs: path: docker-stub-EU.zip if-no-files-found: error - - name: Upload Docker stub US checksum artifact - if: | - github.event_name != 'pull_request' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: docker-stub-US-sha256.txt - path: docker-stub-US-sha256.txt - if-no-files-found: error - - - name: Upload Docker stub EU checksum artifact - if: | - github.event_name != 'pull_request' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: docker-stub-EU-sha256.txt - path: docker-stub-EU-sha256.txt - if-no-files-found: error - - name: Build Public API Swagger run: | cd ./src/Api @@ -512,7 +443,7 @@ jobs: build-mssqlmigratorutility: name: Build MSSQL migrator utility - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: - lint defaults: @@ -568,14 +499,18 @@ jobs: 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 + runs-on: ubuntu-24.04 needs: - - build-docker + - build-artifacts + permissions: + id-token: write steps: - - name: Log in to Azure - CI subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve GitHub PAT secrets id: retrieve-secret-pat @@ -584,6 +519,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Trigger self-host build uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: @@ -604,12 +542,16 @@ jobs: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 needs: - - build-docker + - build-artifacts + permissions: + id-token: write steps: - - name: Log in to Azure - CI subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve GitHub PAT secrets id: retrieve-secret-pat @@ -618,6 +560,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Trigger k8s deploy uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: @@ -638,7 +583,6 @@ jobs: name: Setup Ephemeral Environment needs: - build-artifacts - - build-docker if: | needs.build-artifacts.outputs.has_secrets == 'true' && github.event_name == 'pull_request' @@ -646,8 +590,11 @@ jobs: uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main with: project: server - pull_request_number: ${{ github.event.number }} + pull_request_number: ${{ github.event.number || 0 }} secrets: inherit + permissions: + contents: read + id-token: write check-failures: name: Check for failures @@ -656,11 +603,12 @@ jobs: needs: - lint - build-artifacts - - build-docker - upload - build-mssqlmigratorutility - self-host-build - trigger-k8s-deploy + permissions: + id-token: write steps: - name: Check if any job failed if: | @@ -669,11 +617,12 @@ jobs: && contains(needs.*.result, 'failure') run: exit 1 - - name: Log in to Azure - CI subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 - if: failure() + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -683,6 +632,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Notify Slack on failure uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0 if: failure() diff --git a/.github/workflows/build_target.yml b/.github/workflows/build_target.yml index d825721a7d..20c9cb8ef0 100644 --- a/.github/workflows/build_target.yml +++ b/.github/workflows/build_target.yml @@ -14,6 +14,8 @@ jobs: check-run: name: Check PR run uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + permissions: + contents: read run-workflow: name: Run Build on PR Target @@ -21,3 +23,9 @@ jobs: if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} uses: ./.github/workflows/build.yml secrets: inherit + + permissions: + contents: read + actions: read + id-token: write + security-events: write diff --git a/.github/workflows/cleanup-after-pr.yml b/.github/workflows/cleanup-after-pr.yml index c36dc4a034..e39bf8ea3a 100644 --- a/.github/workflows/cleanup-after-pr.yml +++ b/.github/workflows/cleanup-after-pr.yml @@ -11,11 +11,15 @@ jobs: build-docker: name: Remove branch-specific Docker images runs-on: ubuntu-22.04 + permissions: + id-token: write steps: - - name: Log in to Azure - production subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to Azure ACR run: az acr login -n $_AZ_REGISTRY --only-show-errors @@ -62,3 +66,6 @@ jobs: - name: Log out of Docker run: docker logout + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main diff --git a/.github/workflows/cleanup-rc-branch.yml b/.github/workflows/cleanup-rc-branch.yml index 1ea2eab08a..5c74284423 100644 --- a/.github/workflows/cleanup-rc-branch.yml +++ b/.github/workflows/cleanup-rc-branch.yml @@ -9,11 +9,16 @@ jobs: delete-rc: name: Delete RC Branch runs-on: ubuntu-22.04 + permissions: + contents: write + id-token: write steps: - - name: Login to Azure - CI Subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve bot secrets id: retrieve-bot-secrets @@ -22,6 +27,9 @@ jobs: keyvault: bitwarden-ci secrets: "github-pat-bitwarden-devops-bot-repo-scope" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Checkout main uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index 30fbff32ed..75e0c43306 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -1,25 +1,24 @@ name: Collect code references -on: +on: push: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - check-ld-secret: - name: Check for LD secret + check-secret-access: + name: Check for secret access runs-on: ubuntu-22.04 outputs: - available: ${{ steps.check-ld-secret.outputs.available }} - permissions: - contents: read + available: ${{ steps.check-secret-access.outputs.available }} + permissions: {} steps: - name: Check - id: check-ld-secret + id: check-secret-access run: | - if [ "${{ secrets.LD_ACCESS_TOKEN }}" != '' ]; then + if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then echo "available=true" >> $GITHUB_OUTPUT; else echo "available=false" >> $GITHUB_OUTPUT; @@ -28,22 +27,41 @@ jobs: refs: name: Code reference collection runs-on: ubuntu-22.04 - needs: check-ld-secret - if: ${{ needs.check-ld-secret.outputs.available == 'true' }} + needs: check-secret-access + if: ${{ needs.check-secret-access.outputs.available == 'true' }} permissions: contents: read pull-requests: write + id-token: write steps: - name: Check out repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-server + secrets: "LD-ACCESS-TOKEN" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Collect id: collect uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0 with: - accessToken: ${{ secrets.LD_ACCESS_TOKEN }} + accessToken: ${{ steps.get-kv-secrets.outputs.LD-ACCESS-TOKEN }} projKey: default + allowTags: true - name: Add label if: steps.collect.outputs.any-changed == 'true' diff --git a/.github/workflows/enforce-labels.yml b/.github/workflows/enforce-labels.yml index 11d5654937..353127c751 100644 --- a/.github/workflows/enforce-labels.yml +++ b/.github/workflows/enforce-labels.yml @@ -4,6 +4,9 @@ on: workflow_call: pull_request: types: [labeled, unlabeled, opened, reopened, synchronize] + +permissions: {} + jobs: enforce-label: if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') || contains(github.event.*.labels.*.name, 'ephemeral-environment') }} diff --git a/.github/workflows/ephemeral-environment.yml b/.github/workflows/ephemeral-environment.yml index 6dd89536b6..d85fcf2fd4 100644 --- a/.github/workflows/ephemeral-environment.yml +++ b/.github/workflows/ephemeral-environment.yml @@ -4,6 +4,10 @@ on: pull_request: types: [labeled] +permissions: + contents: read + id-token: write + jobs: setup-ephemeral-environment: name: Setup Ephemeral Environment diff --git a/.github/workflows/protect-files.yml b/.github/workflows/protect-files.yml index 89d6d4c6d9..546b8344a6 100644 --- a/.github/workflows/protect-files.yml +++ b/.github/workflows/protect-files.yml @@ -16,6 +16,9 @@ jobs: changed-files: name: Check for file changes runs-on: ubuntu-22.04 + permissions: + contents: read + pull-requests: write outputs: changes: ${{steps.check-changes.outputs.changes_detected}} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 55220390c4..444c2289d1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,6 +26,9 @@ jobs: setup: name: Setup runs-on: ubuntu-22.04 + permissions: + contents: read + deployments: write outputs: branch-name: ${{ steps.branch.outputs.branch-name }} deployment-id: ${{ steps.deployment.outputs.deployment_id }} @@ -63,6 +66,9 @@ jobs: name: Publish Docker images runs-on: ubuntu-22.04 needs: setup + permissions: + contents: read + id-token: write env: _RELEASE_VERSION: ${{ needs.setup.outputs.release-version }} _BRANCH_NAME: ${{ needs.setup.outputs.branch-name }} @@ -109,10 +115,12 @@ jobs: echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT ########## ACR PROD ########## - - name: Log in to Azure - production subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to Azure ACR run: az acr login -n $_AZ_REGISTRY --only-show-errors @@ -152,12 +160,17 @@ jobs: - name: Log out of Docker run: docker logout + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + update-deployment: name: Update Deployment Status runs-on: ubuntu-22.04 needs: - setup - publish-docker + permissions: + deployments: write if: ${{ always() && inputs.publish_type != 'Dry Run' }} steps: - name: Check if any job failed diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f749d2e4f0..c62587fe39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,9 @@ on: env: _AZ_REGISTRY: "bitwardenprod.azurecr.io" +permissions: + contents: read + jobs: setup: name: Setup @@ -56,6 +59,8 @@ jobs: name: Create GitHub release runs-on: ubuntu-22.04 needs: setup + permissions: + contents: write steps: - name: Download latest release Docker stubs if: ${{ inputs.release_type != 'Dry Run' }} @@ -65,9 +70,7 @@ jobs: workflow_conclusion: success branch: ${{ needs.setup.outputs.branch-name }} artifacts: "docker-stub-US.zip, - docker-stub-US-sha256.txt, docker-stub-EU.zip, - docker-stub-EU-sha256.txt, swagger.json" - name: Dry Run - Download latest release Docker stubs @@ -78,9 +81,7 @@ jobs: workflow_conclusion: success branch: main artifacts: "docker-stub-US.zip, - docker-stub-US-sha256.txt, docker-stub-EU.zip, - docker-stub-EU-sha256.txt, swagger.json" - name: Create release @@ -88,9 +89,7 @@ jobs: uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0 with: artifacts: "docker-stub-US.zip, - docker-stub-US-sha256.txt, docker-stub-EU.zip, - docker-stub-EU-sha256.txt, swagger.json" commit: ${{ github.sha }} tag: "v${{ needs.setup.outputs.release_version }}" diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 178e29212a..b5d6db69d4 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -22,6 +22,8 @@ on: required: false type: string +permissions: {} + jobs: setup: name: Setup @@ -44,53 +46,35 @@ jobs: echo "branch=$BRANCH" >> $GITHUB_OUTPUT - - cut_branch: - name: Cut branch - if: ${{ needs.setup.outputs.branch != 'none' }} - needs: setup - runs-on: ubuntu-24.04 - steps: - - name: Generate GH App token - uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 - id: app-token - with: - app-id: ${{ secrets.BW_GHAPP_ID }} - private-key: ${{ secrets.BW_GHAPP_KEY }} - - - name: Check out target ref - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ inputs.target_ref }} - token: ${{ steps.app-token.outputs.token }} - - - name: Check if ${{ needs.setup.outputs.branch }} branch exists - env: - BRANCH_NAME: ${{ needs.setup.outputs.branch }} - run: | - if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then - echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY - exit 1 - fi - - - name: Cut branch - env: - BRANCH_NAME: ${{ needs.setup.outputs.branch }} - run: | - git switch --quiet --create $BRANCH_NAME - git push --quiet --set-upstream origin $BRANCH_NAME - - bump_version: name: Bump Version if: ${{ always() }} runs-on: ubuntu-24.04 needs: - - cut_branch - setup outputs: version: ${{ steps.set-final-version-output.outputs.version }} + permissions: + id-token: write + steps: + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Validate version input format if: ${{ inputs.version_number_override != '' }} uses: bitwarden/gh-actions/version-check@main @@ -101,8 +85,8 @@ jobs: uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 id: app-token with: - app-id: ${{ secrets.BW_GHAPP_ID }} - private-key: ${{ secrets.BW_GHAPP_KEY }} + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Check out branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -187,94 +171,65 @@ jobs: - name: Push changes run: git push - - cherry_pick: - name: Cherry-Pick Commit(s) + cut_branch: + name: Cut branch if: ${{ needs.setup.outputs.branch != 'none' }} - runs-on: ubuntu-24.04 needs: - - bump_version - setup + - bump_version + runs-on: ubuntu-24.04 + permissions: + id-token: write + steps: + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Generate GH App token uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 id: app-token with: - app-id: ${{ secrets.BW_GHAPP_ID }} - private-key: ${{ secrets.BW_GHAPP_KEY }} + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - - name: Check out main branch + - name: Check out target ref uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - fetch-depth: 0 - ref: main + ref: ${{ inputs.target_ref }} token: ${{ steps.app-token.outputs.token }} - - name: Configure Git - run: | - git config --local user.email "actions@github.com" - git config --local user.name "Github Actions" - - - name: Install xmllint - run: | - sudo apt-get update - sudo apt-get install -y libxml2-utils - - - name: Perform cherry-pick(s) + - name: Check if ${{ needs.setup.outputs.branch }} branch exists env: - CUT_BRANCH: ${{ needs.setup.outputs.branch }} + BRANCH_NAME: ${{ needs.setup.outputs.branch }} run: | - # Function for cherry-picking - cherry_pick () { - local source_branch=$1 - local destination_branch=$2 - - # Get project commit/version from source branch - git switch $source_branch - SOURCE_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props) - SOURCE_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props) - - # Get project commit/version from destination branch - git switch $destination_branch - DESTINATION_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props) - - if [[ "$DESTINATION_VERSION" != "$SOURCE_VERSION" ]]; then - git cherry-pick --strategy-option=theirs -x $SOURCE_COMMIT - git push -u origin $destination_branch - fi - } - - # If we are cutting 'hotfix-rc': - if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then - - # If the 'rc' branch exists: - if [[ $(git ls-remote --heads origin rc) ]]; then - - # Chery-pick from 'rc' into 'hotfix-rc' - cherry_pick rc hotfix-rc - - # Cherry-pick from 'main' into 'rc' - cherry_pick main rc - - # If the 'rc' branch does not exist: - else - - # Cherry-pick from 'main' into 'hotfix-rc' - cherry_pick main hotfix-rc - - fi - - # If we are cutting 'rc': - elif [[ "$CUT_BRANCH" == "rc" ]]; then - - # Cherry-pick from 'main' into 'rc' - cherry_pick main rc - + if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then + echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY + exit 1 fi + - name: Cut branch + env: + BRANCH_NAME: ${{ needs.setup.outputs.branch }} + run: | + git switch --quiet --create $BRANCH_NAME + git push --quiet --set-upstream origin $BRANCH_NAME - move_future_db_scripts: - name: Move finalization database scripts - needs: cherry_pick - uses: ./.github/workflows/_move_finalization_db_scripts.yml + move_edd_db_scripts: + name: Move EDD database scripts + needs: cut_branch + uses: ./.github/workflows/_move_edd_db_scripts.yml secrets: inherit diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index f24a0973fd..04629ec899 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -16,83 +16,42 @@ on: branches: - "main" +permissions: {} + jobs: check-run: name: Check PR run uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + permissions: + contents: read sast: - name: SAST scan - runs-on: ubuntu-22.04 + name: Checkmarx + uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main needs: check-run + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: contents: read pull-requests: write security-events: write - - steps: - - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Scan with Checkmarx - uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41 - env: - INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}" - with: - project_name: ${{ github.repository }} - cx_tenant: ${{ secrets.CHECKMARX_TENANT }} - base_uri: https://ast.checkmarx.net/ - cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} - cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} - additional_params: | - --report-format sarif \ - --filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \ - --output-path . ${{ env.INCREMENTAL }} - - - name: Upload Checkmarx results to GitHub - uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 - with: - sarif_file: cx_result.sarif - sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} - ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} + id-token: write + with: + upload-sarif: false quality: - name: Quality scan - runs-on: ubuntu-22.04 + name: Sonar + uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main needs: check-run + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: contents: read pull-requests: write - - steps: - - name: Set up JDK 17 - uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0 - with: - java-version: 17 - distribution: "zulu" - - - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - - name: Set up .NET - uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 - - - name: Install SonarCloud scanner - run: dotnet tool install dotnet-sonarscanner -g - - - name: Scan with SonarCloud - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: | - dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" \ - /d:sonar.test.inclusions=test/,bitwarden_license/test/ \ - /d:sonar.exclusions=test/,bitwarden_license/test/ \ - /o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \ - /d:sonar.host.url="https://sonarcloud.io" ${{ contains(github.event_name, 'pull_request') && format('/d:sonar.pullrequest.key={0}', github.event.pull_request.number) || '' }} - dotnet build - dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" + id-token: write + with: + sonar-config: "dotnet" diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 9420f71cb3..83d492645e 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -8,6 +8,11 @@ jobs: stale: name: Check for stale issues and PRs runs-on: ubuntu-22.04 + permissions: + actions: write + contents: read + issues: write + pull-requests: write steps: - name: Check uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index 26db5ea0a4..65417f7529 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -31,10 +31,17 @@ on: - "test/Infrastructure.IntegrationTest/**" # Any changes to the tests - "src/**/Entities/**/*.cs" # Database entity definitions +permissions: + contents: read + jobs: test: name: Run tests runs-on: ubuntu-22.04 + permissions: + contents: read + actions: read + checks: write steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -222,11 +229,27 @@ jobs: - name: Validate XML run: | if grep -q "" "report.xml"; then - echo - echo "Migration files are not in sync with the files in the Sql project. Review to make sure that any stored procedures / other db changes match with the stored procedures in the Sql project." + echo "ERROR: Migration files are not in sync with the SQL project" + echo "" + echo "Check these locations:" + echo " - Migration scripts: util/Migrator/DbScripts/" + echo " - SQL project files: src/Sql/" + echo " - Download 'report.xml' artifact for full details" + echo "" + + # Show actual SQL differences - exclude database setup commands + if [ -s "diff.sql" ]; then + echo "Key SQL differences:" + # Show meaningful schema differences, filtering out database setup noise + grep -E "^(CREATE|DROP|ALTER)" diff.sql | grep -v "ALTER DATABASE" | grep -v "DatabaseName" | head -5 + echo "" + fi + + echo "Common causes: naming differences (underscores, case), missing objects, or definition mismatches" + exit 1 else - echo "Report looks good" + echo "SUCCESS: Database validation passed" fi shell: bash diff --git a/.gitignore b/.gitignore index 65157bf4aa..e1b2153433 100644 --- a/.gitignore +++ b/.gitignore @@ -214,6 +214,7 @@ bitwarden_license/src/Sso/wwwroot/assets .idea/* **/**.swp .mono +src/Core/MailTemplates/Mjml/out src/Admin/Admin.zip src/Api/Api.zip diff --git a/Directory.Build.props b/Directory.Build.props index ac814ef8d8..d8af8dc990 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,62 +3,40 @@ net8.0 - 2025.5.1 + 2025.8.0 Bit.$(MSBuildProjectName) enable false - + true annotations - - + enable true - + - + 17.8.0 - + 2.6.6 - + 2.5.6 - + 6.0.0 - + 5.1.0 - + 4.18.1 - + 4.18.1 - + - + @@ -69,5 +47,4 @@ - \ No newline at end of file diff --git a/README.md b/README.md index 73992785d7..c817931c67 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,6 @@ Github Workflow build on main - - DockerHub - gitter chat @@ -26,12 +23,12 @@ Please refer to the [Server Setup Guide](https://contributing.bitwarden.com/gett ## Deploy

- + docker

-You can deploy Bitwarden using Docker containers on Windows, macOS, and Linux distributions. Use the provided PowerShell and Bash scripts to get started quickly. Find all of the Bitwarden images on [Docker Hub](https://hub.docker.com/u/bitwarden/). +You can deploy Bitwarden using Docker containers on Windows, macOS, and Linux distributions. Use the provided PowerShell and Bash scripts to get started quickly. Find all of the Bitwarden images on [GitHub Container Registry](https://github.com/orgs/bitwarden/packages). Full documentation for deploying Bitwarden with Docker can be found in our help center at: https://help.bitwarden.com/article/install-on-premise/ diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 4af0e12e64..ed71b5f438 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; @@ -153,6 +156,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv organization.GatewaySubscriptionId = subscription.Id; organization.Status = OrganizationStatusType.Created; + organization.Enabled = true; await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0); } diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index ad2d2d2aa1..3300b05531 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; @@ -149,7 +152,15 @@ public class ProviderService : IProviderService throw new ArgumentException("Cannot create provider this way."); } + var existingProvider = await _providerRepository.GetByIdAsync(provider.Id); + var enabledStatusChanged = existingProvider != null && existingProvider.Enabled != provider.Enabled; + await _providerRepository.ReplaceAsync(provider); + + if (enabledStatusChanged && (provider.Type == ProviderType.Msp || provider.Type == ProviderType.BusinessUnit)) + { + await UpdateClientOrganizationsEnabledStatusAsync(provider.Id, provider.Enabled); + } } public async Task> InviteUserAsync(ProviderUserInvite invite) @@ -287,11 +298,10 @@ public class ProviderService : IProviderService foreach (var user in users) { - if (!keyedFilteredUsers.ContainsKey(user.Id)) + if (!keyedFilteredUsers.TryGetValue(user.Id, out var providerUser)) { continue; } - var providerUser = keyedFilteredUsers[user.Id]; try { if (providerUser.Status != ProviderUserStatusType.Accepted || providerUser.ProviderId != providerId) @@ -726,4 +736,20 @@ public class ProviderService : IProviderService throw new BadRequestException($"Unsupported provider type {providerType}."); } } + + private async Task UpdateClientOrganizationsEnabledStatusAsync(Guid providerId, bool enabled) + { + var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId); + + foreach (var providerOrganization in providerOrganizations) + { + var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); + if (organization != null && organization.Enabled != enabled) + { + organization.Enabled = enabled; + await _organizationRepository.ReplaceAsync(organization); + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + } + } } diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs index eea40577ad..5fff607f79 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Models/ProviderClientInvoiceReportRow.cs @@ -1,4 +1,7 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using Bit.Core.Billing.Providers.Entities; using CsvHelper.Configuration.Attributes; diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs index 8f6eb07fe1..8e8a89ae58 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs @@ -1,6 +1,5 @@ #nullable enable using System.Diagnostics.CodeAnalysis; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -27,7 +26,6 @@ using Stripe; namespace Bit.Commercial.Core.Billing.Providers.Services; -[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] public class BusinessUnitConverter( IDataProtectionProvider dataProtectionProvider, GlobalSettings globalSettings, diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index 8c90d778bc..e02b52cd46 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -1,4 +1,7 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using Bit.Commercial.Core.Billing.Providers.Models; using Bit.Core; using Bit.Core.AdminConsole.Entities; @@ -550,6 +553,15 @@ public class ProviderBillingService( [ new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber } ]; + + if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) + { + options.TaxIdData.Add(new CustomerTaxIdDataOptions + { + Type = StripeConstants.TaxIdType.EUVAT, + Value = $"ES{taxInfo.TaxIdNumber}" + }); + } } if (!string.IsNullOrEmpty(provider.DiscountId)) diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Porting/ImportCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Porting/ImportCommand.cs index 9520f6f00f..dc389256a1 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Porting/ImportCommand.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Porting/ImportCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.SecretsManager.Commands.Porting; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.SecretsManager.Commands.Porting; using Bit.Core.SecretsManager.Commands.Porting.Interfaces; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs index d54644e292..1a5fe07c21 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Context; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Identity; using Bit.Core.Repositories; diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs index 687291d75a..12c7f679bd 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Repositories; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Repositories; using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/AccessClientQuery.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/AccessClientQuery.cs index 8847ee293f..87548e5b6c 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/AccessClientQuery.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Queries/AccessClientQuery.cs @@ -1,4 +1,7 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.SecretsManager.Queries.Interfaces; diff --git a/bitwarden_license/src/Scim/.dockerignore b/bitwarden_license/src/Scim/.dockerignore deleted file mode 100644 index fc12f25146..0000000000 --- a/bitwarden_license/src/Scim/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!obj/build-output/publish/* -!obj/Docker/empty/ -!entrypoint.sh diff --git a/bitwarden_license/src/Scim/Context/ScimContext.cs b/bitwarden_license/src/Scim/Context/ScimContext.cs index efcc8dbde3..bb0286b919 100644 --- a/bitwarden_license/src/Scim/Context/ScimContext.cs +++ b/bitwarden_license/src/Scim/Context/ScimContext.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.Enums; diff --git a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs index 6da4001753..e3c290c85f 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs index 77bc62e952..afbfa50bb4 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs @@ -1,9 +1,11 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Scim.Models; using Bit.Scim.Users.Interfaces; using Bit.Scim.Utilities; @@ -19,29 +21,28 @@ namespace Bit.Scim.Controllers.v2; public class UsersController : Controller { private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IOrganizationService _organizationService; private readonly IGetUsersListQuery _getUsersListQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IPatchUserCommand _patchUserCommand; private readonly IPostUserCommand _postUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; + private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand; - public UsersController( - IOrganizationUserRepository organizationUserRepository, - IOrganizationService organizationService, + public UsersController(IOrganizationUserRepository organizationUserRepository, IGetUsersListQuery getUsersListQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, IPatchUserCommand patchUserCommand, IPostUserCommand postUserCommand, - IRestoreOrganizationUserCommand restoreOrganizationUserCommand) + IRestoreOrganizationUserCommand restoreOrganizationUserCommand, + IRevokeOrganizationUserCommand revokeOrganizationUserCommand) { _organizationUserRepository = organizationUserRepository; - _organizationService = organizationService; _getUsersListQuery = getUsersListQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; _patchUserCommand = patchUserCommand; _postUserCommand = postUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; + _revokeOrganizationUserCommand = revokeOrganizationUserCommand; } [HttpGet("{id}")] @@ -98,7 +99,7 @@ public class UsersController : Controller } else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked) { - await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM); + await _revokeOrganizationUserCommand.RevokeUserAsync(orgUser, EventSystemUser.SCIM); } // Have to get full details object for response model diff --git a/bitwarden_license/src/Scim/Dockerfile b/bitwarden_license/src/Scim/Dockerfile index 6970dfa7bb..fca3d83572 100644 --- a/bitwarden_license/src/Scim/Dockerfile +++ b/bitwarden_license/src/Scim/Dockerfile @@ -1,19 +1,62 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +# We put the value in a file to be read by later layers. +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-musl-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-musl-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-musl-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/bitwarden_license/src/Scim +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 + +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - krb5-user \ - && rm -rf /var/lib/apt/lists/* - -ENV ASPNETCORE_URLS http://+:5000 -WORKDIR /app +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -COPY obj/build-output/publish . -COPY entrypoint.sh / + +RUN apk add --no-cache curl \ + krb5 \ + icu-libs \ + shadow \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu + +# Copy app from the build stage +WORKDIR /app +COPY --from=build /source/bitwarden_license/src/Scim/out /app +COPY ./bitwarden_license/src/Scim/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 diff --git a/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs b/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs index 7055736a4c..cc6546700b 100644 --- a/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs +++ b/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Scim.Groups.Interfaces; diff --git a/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs b/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs index ab082fc2a6..c83b2c0493 100644 --- a/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs +++ b/bitwarden_license/src/Scim/Groups/PatchGroupCommand.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; diff --git a/bitwarden_license/src/Scim/Models/BaseScimGroupModel.cs b/bitwarden_license/src/Scim/Models/BaseScimGroupModel.cs index 150885fb50..6004c7572a 100644 --- a/bitwarden_license/src/Scim/Models/BaseScimGroupModel.cs +++ b/bitwarden_license/src/Scim/Models/BaseScimGroupModel.cs @@ -1,4 +1,7 @@ -using Bit.Scim.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Scim.Utilities; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/BaseScimModel.cs b/bitwarden_license/src/Scim/Models/BaseScimModel.cs index 8f3adfbe4a..f4e0d9efdb 100644 --- a/bitwarden_license/src/Scim/Models/BaseScimModel.cs +++ b/bitwarden_license/src/Scim/Models/BaseScimModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Scim.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Scim.Models; public abstract class BaseScimModel { diff --git a/bitwarden_license/src/Scim/Models/BaseScimUserModel.cs b/bitwarden_license/src/Scim/Models/BaseScimUserModel.cs index d3c69d574d..eb8ffe88a6 100644 --- a/bitwarden_license/src/Scim/Models/BaseScimUserModel.cs +++ b/bitwarden_license/src/Scim/Models/BaseScimUserModel.cs @@ -1,4 +1,7 @@ -using Bit.Scim.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Scim.Utilities; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/ScimErrorResponseModel.cs b/bitwarden_license/src/Scim/Models/ScimErrorResponseModel.cs index d1dce35ef0..064acc476b 100644 --- a/bitwarden_license/src/Scim/Models/ScimErrorResponseModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimErrorResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Scim.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Scim.Utilities; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/ScimGroupRequestModel.cs b/bitwarden_license/src/Scim/Models/ScimGroupRequestModel.cs index 11bd40c587..3a9c795f58 100644 --- a/bitwarden_license/src/Scim/Models/ScimGroupRequestModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimGroupRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Utilities; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/ScimGroupResponseModel.cs b/bitwarden_license/src/Scim/Models/ScimGroupResponseModel.cs index 697a3d59da..a3d7c2054a 100644 --- a/bitwarden_license/src/Scim/Models/ScimGroupResponseModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimGroupResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/ScimListResponseModel.cs b/bitwarden_license/src/Scim/Models/ScimListResponseModel.cs index 77ab52356c..9f5cc61f97 100644 --- a/bitwarden_license/src/Scim/Models/ScimListResponseModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimListResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Scim.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Scim.Utilities; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/ScimPatchModel.cs b/bitwarden_license/src/Scim/Models/ScimPatchModel.cs index 6707ced85f..5392a18e3c 100644 --- a/bitwarden_license/src/Scim/Models/ScimPatchModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimPatchModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; namespace Bit.Scim.Models; diff --git a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs index 295db790e3..fc4f781e42 100644 --- a/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs +++ b/bitwarden_license/src/Scim/Models/ScimUserRequestModel.cs @@ -1,11 +1,14 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Utilities; -using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite; namespace Bit.Scim.Models; @@ -44,7 +47,7 @@ public class ScimUserRequestModel : BaseScimUserModel return new InviteOrganizationUsersRequest( invites: [ - new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite( + new OrganizationUserInviteCommandModel( email: email, externalId: ExternalIdForInvite() ) diff --git a/bitwarden_license/src/Scim/Program.cs b/bitwarden_license/src/Scim/Program.cs index 5d7d505aac..92f12f59dd 100644 --- a/bitwarden_license/src/Scim/Program.cs +++ b/bitwarden_license/src/Scim/Program.cs @@ -16,8 +16,8 @@ public class Program { var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.ContainsKey("RequestPath") && - !string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && + if (e.Properties.TryGetValue("RequestPath", out var requestPath) && + !string.IsNullOrWhiteSpace(requestPath?.ToString()) && (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) { return false; diff --git a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs index 9bcbcbdafc..a734635ebf 100644 --- a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs +++ b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Data.Organizations.OrganizationUsers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Scim.Users.Interfaces; diff --git a/bitwarden_license/src/Scim/Users/Interfaces/IPostUserCommand.cs b/bitwarden_license/src/Scim/Users/Interfaces/IPostUserCommand.cs index 05dd05510c..401754ad10 100644 --- a/bitwarden_license/src/Scim/Users/Interfaces/IPostUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/Interfaces/IPostUserCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Data.Organizations.OrganizationUsers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Scim.Models; namespace Bit.Scim.Users.Interfaces; diff --git a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs index 3d7082aacc..6c983611ee 100644 --- a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs @@ -1,8 +1,8 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Scim.Models; using Bit.Scim.Users.Interfaces; @@ -11,20 +11,19 @@ namespace Bit.Scim.Users; public class PatchUserCommand : IPatchUserCommand { private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IOrganizationService _organizationService; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly ILogger _logger; + private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand; - public PatchUserCommand( - IOrganizationUserRepository organizationUserRepository, - IOrganizationService organizationService, + public PatchUserCommand(IOrganizationUserRepository organizationUserRepository, IRestoreOrganizationUserCommand restoreOrganizationUserCommand, - ILogger logger) + ILogger logger, + IRevokeOrganizationUserCommand revokeOrganizationUserCommand) { _organizationUserRepository = organizationUserRepository; - _organizationService = organizationService; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; _logger = logger; + _revokeOrganizationUserCommand = revokeOrganizationUserCommand; } public async Task PatchUserAsync(Guid organizationId, Guid id, ScimPatchModel model) @@ -80,7 +79,7 @@ public class PatchUserCommand : IPatchUserCommand } else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked) { - await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM); + await _revokeOrganizationUserCommand.RevokeUserAsync(orgUser, EventSystemUser.SCIM); return true; } return false; diff --git a/bitwarden_license/src/Scim/entrypoint.sh b/bitwarden_license/src/Scim/entrypoint.sh index edc3bbe14a..c3ff43e8dc 100644 --- a/bitwarden_license/src/Scim/entrypoint.sh +++ b/bitwarden_license/src/Scim/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # Setup @@ -19,31 +19,42 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -chown -R $USERNAME:$GROUPNAME /app -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then + chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos + fi + + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi -if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then - chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos - cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf - gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab +if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then + cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf + $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Scim.dll +if [ "$globalSettings__selfHosted" = "true" ]; then + if [ -z "$globalSettings__identityServer__certificateLocation" ]; then + export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx + fi +fi + +exec $gosu_cmd /app/Scim diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index f41d2d3c65..7fadc8cb27 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -1,5 +1,9 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Core; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; @@ -251,18 +255,23 @@ public class AccountController : Controller var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}"); _logger.LogDebug("External claims: {@claims}", externalClaims); - // Lookup our user and external provider info + // See if the user has logged in with this SSO provider before and has already been provisioned. + // This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using. var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result); + + // The user has not authenticated with this SSO provider before. + // They could have an existing Bitwarden account in the User table though. if (user == null) { - // This might be where you might initiate a custom workflow for user registration - // in this sample we don't show how that would be done, as our sample implementation - // simply auto-provisions new external user + // If we're manually linking to SSO, the user's external identifier will be passed as query string parameter. var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ? result.Properties.Items["user_identifier"] : null; user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData); } + // Either the user already authenticated with the SSO provider, or we've just provisioned them. + // Either way, we have associated the SSO login with a Bitwarden user. + // We will now sign the Bitwarden user in. if (user != null) { // This allows us to collect any additional claims or properties @@ -342,6 +351,10 @@ public class AccountController : Controller } } + /// + /// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`. + /// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records. + /// private async Task<(User user, string provider, string providerUserId, IEnumerable claims, SsoConfigurationData config)> FindUserFromExternalProviderAsync(AuthenticateResult result) { @@ -370,8 +383,8 @@ public class AccountController : Controller // for the user identifier. static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier && (c.Properties == null - || !c.Properties.ContainsKey(SamlPropertyKeys.ClaimFormat) - || c.Properties[SamlPropertyKeys.ClaimFormat] != SamlNameIdFormats.Transient); + || !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat, out var claimFormat) + || claimFormat != SamlNameIdFormats.Transient); // Try to determine the unique id of the external user (issued by the provider) // the most common claim type for that are the sub claim and the NameIdentifier @@ -399,6 +412,23 @@ public class AccountController : Controller return (user, provider, providerUserId, claims, ssoConfigData); } + /// + /// Provision an SSO-linked Bitwarden user. + /// This handles three different scenarios: + /// 1. Creating an SsoUser link for an existing User and OrganizationUser + /// - User is a member of the organization, but hasn't authenticated with the org's SSO provider before. + /// 2. Creating a new User and a new OrganizationUser, then establishing an SsoUser link + /// - User is joining the organization through JIT provisioning, without a pending invitation + /// 3. Creating a new User for an existing OrganizationUser (created by invitation), then establishing an SsoUser link + /// - User is signing in with a pending invitation. + /// + /// The external identity provider. + /// The external identity provider's user identifier. + /// The claims from the external IdP. + /// The user identifier used for manual SSO linking. + /// The SSO configuration for the organization. + /// The User to sign in. + /// An exception if the user cannot be provisioned as requested. private async Task AutoProvisionUserAsync(string provider, string providerUserId, IEnumerable claims, string userIdentifier, SsoConfigurationData config) { @@ -426,50 +456,15 @@ public class AccountController : Controller } else { - var split = userIdentifier.Split(","); - if (split.Length < 2) - { - throw new Exception(_i18nService.T("InvalidUserIdentifier")); - } - var userId = split[0]; - var token = split[1]; - - var tokenOptions = new TokenOptions(); - - var claimedUser = await _userService.GetUserByIdAsync(userId); - if (claimedUser != null) - { - var tokenIsValid = await _userManager.VerifyUserTokenAsync( - claimedUser, tokenOptions.PasswordResetTokenProvider, TokenPurposes.LinkSso, token); - if (tokenIsValid) - { - existingUser = claimedUser; - } - else - { - throw new Exception(_i18nService.T("UserIdAndTokenMismatch")); - } - } + existingUser = await GetUserFromManualLinkingData(userIdentifier); } - OrganizationUser orgUser = null; - var organization = await _organizationRepository.GetByIdAsync(orgId); - if (organization == null) - { - throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId)); - } + // Try to find the OrganizationUser if it exists. + var (organization, orgUser) = await FindOrganizationUser(existingUser, email, orgId); - // Try to find OrgUser via existing User Id (accepted/confirmed user) - if (existingUser != null) - { - var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id); - orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId); - } - - // If no Org User found by Existing User Id - search all organization users via email - orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email); - - // All Existing User flows handled below + //---------------------------------------------------- + // Scenario 1: We've found the user in the User table + //---------------------------------------------------- if (existingUser != null) { if (existingUser.UsesKeyConnector && @@ -478,20 +473,22 @@ public class AccountController : Controller throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector")); } + // If the user already exists in Bitwarden, we require that the user already be in the org, + // and that they are either Accepted or Confirmed. if (orgUser == null) { // Org User is not created - no invite has been sent throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess")); } - if (orgUser.Status == OrganizationUserStatusType.Invited) - { - // Org User is invited - they must manually accept the invite via email and authenticate with MP - // This allows us to enroll them in MP reset if required - throw new Exception(_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName())); - } + EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(), + allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]); - // Accepted or Confirmed - create SSO link and return; + + // Since we're in the auto-provisioning logic, this means that the user exists, but they have not + // authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them). + // We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed + // with authentication. await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser); return existingUser; } @@ -499,9 +496,9 @@ public class AccountController : Controller // Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one if (orgUser == null && organization.Seats.HasValue) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var initialSeatCount = organization.Seats.Value; - var availableSeats = initialSeatCount - occupiedSeats; + var availableSeats = initialSeatCount - occupiedSeats.Total; if (availableSeats < 1) { try @@ -534,7 +531,9 @@ public class AccountController : Controller emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false; } - // Create user record - all existing user flows are handled above + //-------------------------------------------------- + // Scenarios 2 and 3: We need to register a new user + //-------------------------------------------------- var user = new User { Name = name, @@ -560,7 +559,11 @@ public class AccountController : Controller await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email); } - // Create Org User if null or else update existing Org User + //----------------------------------------------------------------- + // Scenario 2: We also need to create an OrganizationUser + // This means that an invitation was not sent for this user and we + // need to establish their invited status now. + //----------------------------------------------------------------- if (orgUser == null) { orgUser = new OrganizationUser @@ -572,18 +575,107 @@ public class AccountController : Controller }; await _organizationUserRepository.CreateAsync(orgUser); } + //----------------------------------------------------------------- + // Scenario 3: There is already an existing OrganizationUser + // That was established through an invitation. We just need to + // update the UserId now that we have created a User record. + //----------------------------------------------------------------- else { orgUser.UserId = user.Id; await _organizationUserRepository.ReplaceAsync(orgUser); } - // Create sso user record + // Create the SsoUser record to link the user to the SSO provider. await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser); return user; } + private async Task GetUserFromManualLinkingData(string userIdentifier) + { + User user = null; + var split = userIdentifier.Split(","); + if (split.Length < 2) + { + throw new Exception(_i18nService.T("InvalidUserIdentifier")); + } + var userId = split[0]; + var token = split[1]; + + var tokenOptions = new TokenOptions(); + + var claimedUser = await _userService.GetUserByIdAsync(userId); + if (claimedUser != null) + { + var tokenIsValid = await _userManager.VerifyUserTokenAsync( + claimedUser, tokenOptions.PasswordResetTokenProvider, TokenPurposes.LinkSso, token); + if (tokenIsValid) + { + user = claimedUser; + } + else + { + throw new Exception(_i18nService.T("UserIdAndTokenMismatch")); + } + } + return user; + } + + private async Task<(Organization, OrganizationUser)> FindOrganizationUser(User existingUser, string email, Guid orgId) + { + OrganizationUser orgUser = null; + var organization = await _organizationRepository.GetByIdAsync(orgId); + if (organization == null) + { + throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId)); + } + + // Try to find OrgUser via existing User Id. + // This covers any OrganizationUser state after they have accepted an invite. + if (existingUser != null) + { + var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id); + orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId); + } + + // If no Org User found by Existing User Id - search all the organization's users via email. + // This covers users who are Invited but haven't accepted their invite yet. + orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email); + + return (organization, orgUser); + } + + private void EnsureOrgUserStatusAllowed( + OrganizationUserStatusType status, + string organizationDisplayName, + params OrganizationUserStatusType[] allowedStatuses) + { + // if this status is one of the allowed ones, just return + if (allowedStatuses.Contains(status)) + { + return; + } + + // otherwise throw the appropriate exception + switch (status) + { + case OrganizationUserStatusType.Invited: + // Org User is invited – must accept via email first + throw new Exception( + _i18nService.T("AcceptInviteBeforeUsingSSO", organizationDisplayName)); + case OrganizationUserStatusType.Revoked: + // Revoked users may not be (auto)‑provisioned + throw new Exception( + _i18nService.T("OrganizationUserAccessRevoked", organizationDisplayName)); + default: + // anything else is “unknown” + throw new Exception( + _i18nService.T("OrganizationUserUnknownStatus", organizationDisplayName)); + } + } + + private IActionResult InvalidJson(string errorMessageKey, Exception ex = null) { Response.StatusCode = ex == null ? 400 : 500; diff --git a/bitwarden_license/src/Sso/Controllers/HomeController.cs b/bitwarden_license/src/Sso/Controllers/HomeController.cs index 7be9d86215..da30d5106d 100644 --- a/bitwarden_license/src/Sso/Controllers/HomeController.cs +++ b/bitwarden_license/src/Sso/Controllers/HomeController.cs @@ -1,4 +1,7 @@ -using System.Diagnostics; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Diagnostics; using Bit.Sso.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authorization; diff --git a/bitwarden_license/src/Sso/Dockerfile b/bitwarden_license/src/Sso/Dockerfile index 6970dfa7bb..cbd049b9bd 100644 --- a/bitwarden_license/src/Sso/Dockerfile +++ b/bitwarden_license/src/Sso/Dockerfile @@ -1,19 +1,62 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +# We put the value in a file to be read by later layers. +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-musl-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-musl-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-musl-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/bitwarden_license/src/Sso +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 + +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - krb5-user \ - && rm -rf /var/lib/apt/lists/* - -ENV ASPNETCORE_URLS http://+:5000 -WORKDIR /app +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -COPY obj/build-output/publish . -COPY entrypoint.sh / + +RUN apk add --no-cache curl \ + krb5 \ + icu-libs \ + shadow \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu + +# Copy app from the build stage +WORKDIR /app +COPY --from=build /source/bitwarden_license/src/Sso/out /app +COPY ./bitwarden_license/src/Sso/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 diff --git a/bitwarden_license/src/Sso/Models/ErrorViewModel.cs b/bitwarden_license/src/Sso/Models/ErrorViewModel.cs index 1f6e9735e7..8efb95b09e 100644 --- a/bitwarden_license/src/Sso/Models/ErrorViewModel.cs +++ b/bitwarden_license/src/Sso/Models/ErrorViewModel.cs @@ -1,4 +1,7 @@ -using Duende.IdentityServer.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Duende.IdentityServer.Models; namespace Bit.Sso.Models; diff --git a/bitwarden_license/src/Sso/Models/RedirectViewModel.cs b/bitwarden_license/src/Sso/Models/RedirectViewModel.cs index 9bc294d96c..0e2a642207 100644 --- a/bitwarden_license/src/Sso/Models/RedirectViewModel.cs +++ b/bitwarden_license/src/Sso/Models/RedirectViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Sso.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Sso.Models; public class RedirectViewModel { diff --git a/bitwarden_license/src/Sso/Models/SamlEnvironment.cs b/bitwarden_license/src/Sso/Models/SamlEnvironment.cs index 6de718029a..0a7dbcd44b 100644 --- a/bitwarden_license/src/Sso/Models/SamlEnvironment.cs +++ b/bitwarden_license/src/Sso/Models/SamlEnvironment.cs @@ -1,4 +1,7 @@ -using System.Security.Cryptography.X509Certificates; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Cryptography.X509Certificates; namespace Bit.Sso.Models; diff --git a/bitwarden_license/src/Sso/Program.cs b/bitwarden_license/src/Sso/Program.cs index 051caca9c2..1a8ce6eb88 100644 --- a/bitwarden_license/src/Sso/Program.cs +++ b/bitwarden_license/src/Sso/Program.cs @@ -17,8 +17,8 @@ public class Program logging.AddSerilog(hostingContext, (e, globalSettings) => { var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.ContainsKey("RequestPath") && - !string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && + if (e.Properties.TryGetValue("RequestPath", out var requestPath) && + !string.IsNullOrWhiteSpace(requestPath?.ToString()) && (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) { return false; diff --git a/bitwarden_license/src/Sso/Utilities/ClaimsExtensions.cs b/bitwarden_license/src/Sso/Utilities/ClaimsExtensions.cs index f82614635c..7c34217805 100644 --- a/bitwarden_license/src/Sso/Utilities/ClaimsExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/ClaimsExtensions.cs @@ -1,4 +1,7 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using System.Text.RegularExpressions; namespace Bit.Sso.Utilities; diff --git a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs index 8bde8f84a1..c65d7435c3 100644 --- a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs +++ b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs @@ -1,4 +1,7 @@ -using System.Security.Cryptography.X509Certificates; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Cryptography.X509Certificates; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; diff --git a/bitwarden_license/src/Sso/Utilities/ExtendedOptionsMonitorCache.cs b/bitwarden_license/src/Sso/Utilities/ExtendedOptionsMonitorCache.cs index 083417f25b..4f95e4bf39 100644 --- a/bitwarden_license/src/Sso/Utilities/ExtendedOptionsMonitorCache.cs +++ b/bitwarden_license/src/Sso/Utilities/ExtendedOptionsMonitorCache.cs @@ -1,4 +1,7 @@ -using System.Collections.Concurrent; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Collections.Concurrent; using Microsoft.Extensions.Options; namespace Bit.Sso.Utilities; diff --git a/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs b/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs index 9221877a04..199d8475a6 100644 --- a/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/OpenIdConnectOptionsExtensions.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Authentication.OpenIdConnect; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace Bit.Sso.Utilities; @@ -46,9 +49,9 @@ public static class OpenIdConnectOptionsExtensions // Handle State if we've gotten that back var decodedState = options.StateDataFormat.Unprotect(state); - if (decodedState != null && decodedState.Items.ContainsKey("scheme")) + if (decodedState != null && decodedState.Items.TryGetValue("scheme", out var stateScheme)) { - return decodedState.Items["scheme"] == scheme; + return stateScheme == scheme; } } catch diff --git a/bitwarden_license/src/Sso/Utilities/Saml2OptionsExtensions.cs b/bitwarden_license/src/Sso/Utilities/Saml2OptionsExtensions.cs index 46a75ca5c2..55ee63e91a 100644 --- a/bitwarden_license/src/Sso/Utilities/Saml2OptionsExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/Saml2OptionsExtensions.cs @@ -1,4 +1,7 @@ -using System.IO.Compression; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.IO.Compression; using System.Text; using System.Xml; using Sustainsys.Saml2; diff --git a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs index b1c0a55cbe..a51a04f5c8 100644 --- a/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Sso/Utilities/ServiceCollectionExtensions.cs @@ -1,4 +1,7 @@ -using Bit.Core.Business.Sso; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Business.Sso; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; diff --git a/bitwarden_license/src/Sso/entrypoint.sh b/bitwarden_license/src/Sso/entrypoint.sh index 2c7bd18b84..6ae590f18c 100644 --- a/bitwarden_license/src/Sso/entrypoint.sh +++ b/bitwarden_license/src/Sso/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # Setup @@ -19,37 +19,42 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -mkdir -p /etc/bitwarden/identity -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/identity/identity.pfx /app/identity.pfx + if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then + chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos + fi + + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi -chown -R $USERNAME:$GROUPNAME /app - -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates +if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then + cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf + $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then - chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos - cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf - gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab +if [ "$globalSettings__selfHosted" = "true" ]; then + if [ -z "$globalSettings__identityServer__certificateLocation" ]; then + export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx + fi fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Sso.dll +exec $gosu_cmd /app/Sso diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index 636d6317a1..a6db196d48 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -17,7 +17,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.88.0", + "sass": "1.89.2", "sass-loader": "16.0.5", "webpack": "5.99.8", "webpack-cli": "5.1.4" @@ -1860,9 +1860,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.88.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz", - "integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==", + "version": "1.89.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", + "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index 137f86680c..064cf6d656 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -16,7 +16,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.88.0", + "sass": "1.89.2", "sass-loader": "16.0.5", "webpack": "5.99.8", "webpack-cli": "5.1.4" diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index 5be18116c0..c9b5b93d5e 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -263,7 +263,8 @@ public class RemoveOrganizationFromProviderCommandTests org => org.BillingEmail == "a@example.com" && org.GatewaySubscriptionId == "subscription_id" && - org.Status == OrganizationStatusType.Created)); + org.Status == OrganizationStatusType.Created && + org.Enabled == true)); // Verify organization is enabled when new subscription is created await sutProvider.GetDependency().Received(1) .DeleteAsync(providerOrganization); @@ -354,7 +355,8 @@ public class RemoveOrganizationFromProviderCommandTests org => org.BillingEmail == "a@example.com" && org.GatewaySubscriptionId == "subscription_id" && - org.Status == OrganizationStatusType.Created)); + org.Status == OrganizationStatusType.Created && + org.Enabled == true)); // Verify organization is enabled when new subscription is created await sutProvider.GetDependency().Received(1) .DeleteAsync(providerOrganization); @@ -390,4 +392,62 @@ public class RemoveOrganizationFromProviderCommandTests } } }; + + [Theory, BitAutoData] + public async Task RemoveOrganizationFromProvider_DisabledOrganization_ConsolidatedBilling_EnablesOrganization( + Provider provider, + ProviderOrganization providerOrganization, + Organization organization, + SutProvider sutProvider) + { + // Arrange: Set up a disabled organization that meets the criteria for consolidated billing + provider.Status = ProviderStatusType.Billable; + providerOrganization.ProviderId = provider.Id; + organization.Status = OrganizationStatusType.Managed; + organization.PlanType = PlanType.TeamsMonthly; + organization.Enabled = false; // Start with a disabled organization + + var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + + sutProvider.GetDependency().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); + + sutProvider.GetDependency().HasConfirmedOwnersExceptAsync( + providerOrganization.OrganizationId, + [], + includeProvider: false) + .Returns(true); + + var organizationRepository = sutProvider.GetDependency(); + + organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([ + "owner@example.com" + ]); + + var stripeAdapter = sutProvider.GetDependency(); + + stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(new Customer + { + Id = "customer_id", + Address = new Address + { + Country = "US" + } + }); + + stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(new Subscription + { + Id = "new_subscription_id" + }); + + // Act + await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); + + // Assert: Verify the disabled organization is now enabled + await organizationRepository.Received(1).ReplaceAsync(Arg.Is( + org => + org.Enabled == true && // The previously disabled organization should now be enabled + org.Status == OrganizationStatusType.Created && + org.GatewaySubscriptionId == "new_subscription_id")); + } } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index cb8a9e8c69..608b4b3034 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; +using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; @@ -188,6 +189,262 @@ public class ProviderServiceTests await sutProvider.Sut.UpdateAsync(provider); } + [Theory, BitAutoData] + public async Task UpdateAsync_ExistingProviderIsNull_DoesNotCallUpdateClientOrganizationsEnabledStatus( + Provider provider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + + providerRepository.GetByIdAsync(provider.Id).Returns((Provider)null); + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EnabledStatusNotChanged_DoesNotCallUpdateClientOrganizationsEnabledStatus( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = provider.Enabled; // Same enabled status + provider.Type = ProviderType.Msp; // Set to a type that would trigger update if status changed + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EnabledStatusChangedButProviderTypeIsReseller_DoesNotCallUpdateClientOrganizationsEnabledStatus( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.Reseller; // Type that should not trigger update + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EnabledStatusChangedAndProviderTypeIsMsp_CallsUpdateClientOrganizationsEnabledStatus( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.Msp; // Type that should trigger update + + // Create test provider organization details + var providerOrganizationDetails = new List + { + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }, + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() } + }; + + // Create test organizations with different enabled status than what we're setting + var organizations = providerOrganizationDetails.Select(po => + { + var org = new Organization { Id = po.OrganizationId, Enabled = !provider.Enabled }; + return org; + }).ToList(); + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails); + + foreach (var org in organizations) + { + organizationRepository.GetByIdAsync(org.Id).Returns(org); + } + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id); + + foreach (var org in organizations) + { + await organizationRepository.Received(1).ReplaceAsync(Arg.Is(o => + o.Id == org.Id && o.Enabled == provider.Enabled)); + await applicationCacheService.Received(1).UpsertOrganizationAbilityAsync(Arg.Is(o => + o.Id == org.Id && o.Enabled == provider.Enabled)); + } + } + + [Theory, BitAutoData] + public async Task UpdateAsync_EnabledStatusChangedAndProviderTypeIsBusinessUnit_CallsUpdateClientOrganizationsEnabledStatus( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.BusinessUnit; // Type that should trigger update + + // Create test provider organization details + var providerOrganizationDetails = new List + { + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }, + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() } + }; + + // Create test organizations with different enabled status than what we're setting + var organizations = providerOrganizationDetails.Select(po => + { + var org = new Organization { Id = po.OrganizationId, Enabled = !provider.Enabled }; + return org; + }).ToList(); + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails); + + foreach (var org in organizations) + { + organizationRepository.GetByIdAsync(org.Id).Returns(org); + } + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id); + + foreach (var org in organizations) + { + await organizationRepository.Received(1).ReplaceAsync(Arg.Is(o => + o.Id == org.Id && o.Enabled == provider.Enabled)); + await applicationCacheService.Received(1).UpsertOrganizationAbilityAsync(Arg.Is(o => + o.Id == org.Id && o.Enabled == provider.Enabled)); + } + } + + [Theory, BitAutoData] + public async Task UpdateAsync_OrganizationEnabledStatusAlreadyMatches_DoesNotUpdateOrganization( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.Msp; // Type that should trigger update + + // Create test provider organization details + var providerOrganizationDetails = new List + { + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }, + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() } + }; + + // Create test organizations with SAME enabled status as what we're setting + var organizations = providerOrganizationDetails.Select(po => + { + var org = new Organization { Id = po.OrganizationId, Enabled = provider.Enabled }; + return org; + }).ToList(); + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails); + + foreach (var org in organizations) + { + organizationRepository.GetByIdAsync(org.Id).Returns(org); + } + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id); + + // Organizations should not be updated since their enabled status already matches + foreach (var org in organizations) + { + await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + await applicationCacheService.DidNotReceive().UpsertOrganizationAbilityAsync(Arg.Any()); + } + } + + [Theory, BitAutoData] + public async Task UpdateAsync_OrganizationIsNull_SkipsNullOrganization( + Provider provider, Provider existingProvider, SutProvider sutProvider) + { + // Arrange + var providerRepository = sutProvider.GetDependency(); + var providerOrganizationRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var applicationCacheService = sutProvider.GetDependency(); + + existingProvider.Id = provider.Id; + existingProvider.Enabled = !provider.Enabled; // Different enabled status + provider.Type = ProviderType.Msp; // Type that should trigger update + + // Create test provider organization details + var providerOrganizationDetails = new List + { + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }, + new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() } + }; + + providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider); + providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails); + + // Return null for all organizations + organizationRepository.GetByIdAsync(Arg.Any()).Returns((Organization)null); + + // Act + await sutProvider.Sut.UpdateAsync(provider); + + // Assert + await providerRepository.Received(1).ReplaceAsync(provider); + await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id); + + // No organizations should be updated since they're all null + await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + await applicationCacheService.DidNotReceive().UpsertOrganizationAbilityAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task InviteUserAsync_ProviderIdIsInvalid_Throws(ProviderUserInvite invite, SutProvider sutProvider) { diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests.cs index 4f87396824..17c92443cc 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests.cs @@ -295,7 +295,7 @@ public class ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests { sutProvider.GetDependency().AccessSecretsManager(resource.OrganizationId) .Returns(true); - sutProvider.GetDependency().GetAccessClientAsync(default, resource.OrganizationId) + sutProvider.GetDependency().GetAccessClientAsync(default!, resource.OrganizationId) .ReturnsForAnyArgs((accessClientType, userId)); } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ServiceAccountGrantedPoliciesAuthorizationHandlerTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ServiceAccountGrantedPoliciesAuthorizationHandlerTests.cs index 6f36684c44..45fe8c588f 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ServiceAccountGrantedPoliciesAuthorizationHandlerTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/AccessPolicies/ServiceAccountGrantedPoliciesAuthorizationHandlerTests.cs @@ -247,7 +247,7 @@ public class ServiceAccountGrantedPoliciesAuthorizationHandlerTests { sutProvider.GetDependency().AccessSecretsManager(resource.OrganizationId) .Returns(true); - sutProvider.GetDependency().GetAccessClientAsync(default, resource.OrganizationId) + sutProvider.GetDependency().GetAccessClientAsync(default!, resource.OrganizationId) .ReturnsForAnyArgs((accessClientType, userId)); } diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/Secrets/BulkSecretAuthorizationHandlerTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/Secrets/BulkSecretAuthorizationHandlerTests.cs index d7dc11ba70..a015b1a02a 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/Secrets/BulkSecretAuthorizationHandlerTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/AuthorizationHandlers/Secrets/BulkSecretAuthorizationHandlerTests.cs @@ -207,7 +207,7 @@ public class BulkSecretAuthorizationHandlerTests { sutProvider.GetDependency().AccessSecretsManager(organizationId) .Returns(true); - sutProvider.GetDependency().GetAccessClientAsync(default, organizationId) + sutProvider.GetDependency().GetAccessClientAsync(default!, organizationId) .ReturnsForAnyArgs((accessClientType, userId)); } diff --git a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs index 44a43d16b7..f391c93fe3 100644 --- a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs @@ -1,10 +1,10 @@ using System.Text.Json; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Scim.Models; using Bit.Scim.Users; using Bit.Scim.Utilities; @@ -101,7 +101,7 @@ public class PatchUserCommandTests await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); - await sutProvider.GetDependency().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM); + await sutProvider.GetDependency().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM); } [Theory] @@ -129,7 +129,7 @@ public class PatchUserCommandTests await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); - await sutProvider.GetDependency().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM); + await sutProvider.GetDependency().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM); } [Theory] @@ -149,7 +149,7 @@ public class PatchUserCommandTests await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM); } [Theory] diff --git a/dev/.env.example b/dev/.env.example index 7f049728d7..f31b5b9eeb 100644 --- a/dev/.env.example +++ b/dev/.env.example @@ -26,3 +26,12 @@ IDENTITY_PROXY_PORT=33756 # Optional RabbitMQ configuration RABBITMQ_DEFAULT_USER=bitwarden RABBITMQ_DEFAULT_PASS=SET_A_PASSWORD_HERE_123 + +# Environment variables that help customize dev container start +# Without these the dev container will ask these questions in an interactive manner +# when possible (excluding running in GitHub Codespaces) +# SETUP_SECRETS_JSON=yes +# SETUP_AZURITE=yes +# RUN_MSSQL_MIGRATIONS=yes +# DEV_CERT_PASSWORD=dev_cert_password_here +# INSTALL_STRIPE_CLI=no diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 601989a473..0ee4aa53a9 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -99,7 +99,7 @@ services: - idp rabbitmq: - image: rabbitmq:management + image: rabbitmq:4.1.0-management container_name: rabbitmq ports: - "5672:5672" @@ -108,7 +108,7 @@ services: RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} volumes: - - rabbitmq_data:/var/lib/rabbitmq_data + - rabbitmq_data:/var/lib/rabbitmq profiles: - rabbitmq diff --git a/dev/servicebusemulator_config.json b/dev/servicebusemulator_config.json index 073a44618f..dcf48b7a8c 100644 --- a/dev/servicebusemulator_config.json +++ b/dev/servicebusemulator_config.json @@ -31,6 +31,56 @@ }, { "Name": "events-webhook-subscription" + }, + { + "Name": "events-hec-subscription" + } + ] + }, + { + "Name": "event-integrations", + "Subscriptions": [ + { + "Name": "integration-slack-subscription", + "Rules": [ + { + "Name": "slack-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "slack" + } + } + } + ] + }, + { + "Name": "integration-webhook-subscription", + "Rules": [ + { + "Name": "webhook-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "webhook" + } + } + } + ] + }, + { + "Name": "integration-hec-subscription", + "Rules": [ + { + "Name": "hec-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "hec" + } + } + } + ] } ] } diff --git a/dev/setup_azurite.ps1 b/dev/setup_azurite.ps1 index ad9808f6c3..03b92d4465 100755 --- a/dev/setup_azurite.ps1 +++ b/dev/setup_azurite.ps1 @@ -11,7 +11,7 @@ $corsRules = (@{ AllowedMethods = @("Get", "PUT"); }); $containers = "attachments", "sendfiles", "misc"; -$queues = "event", "notifications", "reference-events", "mail"; +$queues = "event", "notifications", "mail"; $tables = "event", "metadata", "installationdevice"; # End configuration diff --git a/global.json b/global.json index d04c13bbb5..d25197db39 100644 --- a/global.json +++ b/global.json @@ -5,6 +5,6 @@ }, "msbuild-sdks": { "Microsoft.Build.Traversal": "4.1.0", - "Microsoft.Build.Sql": "0.1.9-preview" + "Microsoft.Build.Sql": "1.0.0" } } diff --git a/perf/MicroBenchmarks/Identity/IdentityServer/StaticClientStoreTests.cs b/perf/MicroBenchmarks/Identity/IdentityServer/StaticClientStoreTests.cs index 9d88f960ea..fcdda22c10 100644 --- a/perf/MicroBenchmarks/Identity/IdentityServer/StaticClientStoreTests.cs +++ b/perf/MicroBenchmarks/Identity/IdentityServer/StaticClientStoreTests.cs @@ -20,7 +20,7 @@ public class StaticClientStoreTests [Benchmark] public Client? TryGetValue() { - return _store.ApiClients.TryGetValue(ClientId, out var client) + return _store.Clients.TryGetValue(ClientId, out var client) ? client : null; } diff --git a/src/Admin/.dockerignore b/src/Admin/.dockerignore deleted file mode 100644 index fc12f25146..0000000000 --- a/src/Admin/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!obj/build-output/publish/* -!obj/Docker/empty/ -!entrypoint.sh diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 8cd2222dbf..2417bf610d 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Admin.AdminConsole.Models; using Bit.Admin.Enums; using Bit.Admin.Services; @@ -6,13 +9,13 @@ using Bit.Admin.Utilities; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; -using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -20,9 +23,6 @@ using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault.Repositories; using Microsoft.AspNetCore.Authorization; @@ -33,7 +33,6 @@ namespace Bit.Admin.AdminConsole.Controllers; [Authorize] public class OrganizationsController : Controller { - private readonly IOrganizationService _organizationService; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationConnectionRepository _organizationConnectionRepository; @@ -45,12 +44,9 @@ public class OrganizationsController : Controller private readonly IPaymentService _paymentService; private readonly IApplicationCacheService _applicationCacheService; private readonly GlobalSettings _globalSettings; - private readonly IReferenceEventService _referenceEventService; - private readonly IUserService _userService; private readonly IProviderRepository _providerRepository; private readonly ILogger _logger; private readonly IAccessControlService _accessControlService; - private readonly ICurrentContext _currentContext; private readonly ISecretRepository _secretRepository; private readonly IProjectRepository _projectRepository; private readonly IServiceAccountRepository _serviceAccountRepository; @@ -59,9 +55,9 @@ public class OrganizationsController : Controller private readonly IProviderBillingService _providerBillingService; private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand; private readonly IPricingClient _pricingClient; + private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; public OrganizationsController( - IOrganizationService organizationService, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationConnectionRepository organizationConnectionRepository, @@ -73,12 +69,9 @@ public class OrganizationsController : Controller IPaymentService paymentService, IApplicationCacheService applicationCacheService, GlobalSettings globalSettings, - IReferenceEventService referenceEventService, - IUserService userService, IProviderRepository providerRepository, ILogger logger, IAccessControlService accessControlService, - ICurrentContext currentContext, ISecretRepository secretRepository, IProjectRepository projectRepository, IServiceAccountRepository serviceAccountRepository, @@ -86,9 +79,9 @@ public class OrganizationsController : Controller IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, IProviderBillingService providerBillingService, IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand, - IPricingClient pricingClient) + IPricingClient pricingClient, + IResendOrganizationInviteCommand resendOrganizationInviteCommand) { - _organizationService = organizationService; _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _organizationConnectionRepository = organizationConnectionRepository; @@ -100,12 +93,9 @@ public class OrganizationsController : Controller _paymentService = paymentService; _applicationCacheService = applicationCacheService; _globalSettings = globalSettings; - _referenceEventService = referenceEventService; - _userService = userService; _providerRepository = providerRepository; _logger = logger; _accessControlService = accessControlService; - _currentContext = currentContext; _secretRepository = secretRepository; _projectRepository = projectRepository; _serviceAccountRepository = serviceAccountRepository; @@ -114,6 +104,7 @@ public class OrganizationsController : Controller _providerBillingService = providerBillingService; _organizationInitiateDeleteCommand = organizationInitiateDeleteCommand; _pricingClient = pricingClient; + _resendOrganizationInviteCommand = resendOrganizationInviteCommand; } [RequirePermission(Permission.Org_List_View)] @@ -255,10 +246,32 @@ public class OrganizationsController : Controller Seats = organization.Seats }; + if (model.PlanType.HasValue) + { + var freePlan = await _pricingClient.GetPlanOrThrow(model.PlanType.Value); + var isDowngradingToFree = organization.PlanType != PlanType.Free && model.PlanType.Value == PlanType.Free; + if (isDowngradingToFree) + { + if (model.Seats.HasValue && model.Seats.Value > freePlan.PasswordManager.MaxSeats) + { + TempData["Error"] = $"Organizations with more than {freePlan.PasswordManager.MaxSeats} seats cannot be downgraded to the Free plan"; + return RedirectToAction("Edit", new { id }); + } + + if (model.MaxCollections > freePlan.PasswordManager.MaxCollections) + { + TempData["Error"] = $"Organizations with more than {freePlan.PasswordManager.MaxCollections} collections cannot be downgraded to the Free plan. Your organization currently has {organization.MaxCollections} collections."; + return RedirectToAction("Edit", new { id }); + } + + model.MaxStorageGb = null; + model.ExpirationDate = null; + model.Enabled = true; + } + } + UpdateOrganization(organization, model); - var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); - if (organization.UseSecretsManager && !plan.SupportsSecretsManager) { TempData["Error"] = "Plan does not support Secrets Manager"; @@ -272,11 +285,6 @@ public class OrganizationsController : Controller await _organizationRepository.ReplaceAsync(organization); await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationEditedByAdmin, organization, _currentContext) - { - EventRaisedByUser = _userService.GetUserName(User), - SalesAssistedTrialStarted = model.SalesAssistedTrialStarted, - }); return RedirectToAction("Edit", new { id }); } @@ -388,7 +396,7 @@ public class OrganizationsController : Controller var organizationUsers = await _organizationUserRepository.GetManyByOrganizationAsync(id, OrganizationUserType.Owner); foreach (var organizationUser in organizationUsers) { - await _organizationService.ResendInviteAsync(id, null, organizationUser.Id, true); + await _resendOrganizationInviteCommand.ResendInviteAsync(id, null, organizationUser.Id, true); } return Json(null); diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index b4abf81ee2..df333d5d4e 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -1,11 +1,16 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Net; using Bit.Admin.AdminConsole.Models; using Bit.Admin.Enums; +using Bit.Admin.Services; using Bit.Admin.Utilities; using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; @@ -34,51 +39,50 @@ namespace Bit.Admin.AdminConsole.Controllers; public class ProvidersController : Controller { private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationService _organizationService; + private readonly IResellerClientOrganizationSignUpCommand _resellerClientOrganizationSignUpCommand; private readonly IProviderRepository _providerRepository; private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly GlobalSettings _globalSettings; private readonly IApplicationCacheService _applicationCacheService; private readonly IProviderService _providerService; - private readonly IUserService _userService; private readonly ICreateProviderCommand _createProviderCommand; private readonly IFeatureService _featureService; private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderBillingService _providerBillingService; private readonly IPricingClient _pricingClient; private readonly IStripeAdapter _stripeAdapter; + private readonly IAccessControlService _accessControlService; private readonly string _stripeUrl; private readonly string _braintreeMerchantUrl; private readonly string _braintreeMerchantId; public ProvidersController( IOrganizationRepository organizationRepository, - IOrganizationService organizationService, + IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand, IProviderRepository providerRepository, IProviderUserRepository providerUserRepository, IProviderOrganizationRepository providerOrganizationRepository, IProviderService providerService, GlobalSettings globalSettings, IApplicationCacheService applicationCacheService, - IUserService userService, ICreateProviderCommand createProviderCommand, IFeatureService featureService, IProviderPlanRepository providerPlanRepository, IProviderBillingService providerBillingService, IWebHostEnvironment webHostEnvironment, IPricingClient pricingClient, - IStripeAdapter stripeAdapter) + IStripeAdapter stripeAdapter, + IAccessControlService accessControlService) { _organizationRepository = organizationRepository; - _organizationService = organizationService; + _resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand; _providerRepository = providerRepository; _providerUserRepository = providerUserRepository; _providerOrganizationRepository = providerOrganizationRepository; _providerService = providerService; _globalSettings = globalSettings; _applicationCacheService = applicationCacheService; - _userService = userService; _createProviderCommand = createProviderCommand; _featureService = featureService; _providerPlanRepository = providerPlanRepository; @@ -88,6 +92,7 @@ public class ProvidersController : Controller _stripeUrl = webHostEnvironment.GetStripeUrl(); _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantId = globalSettings.Braintree.MerchantId; + _accessControlService = accessControlService; } [RequirePermission(Permission.Provider_List_View)] @@ -290,9 +295,14 @@ public class ProvidersController : Controller return View(oldModel); } + var originalProviderStatus = provider.Enabled; + model.ToProvider(provider); - await _providerRepository.ReplaceAsync(provider); + provider.Enabled = _accessControlService.UserHasPermission(Permission.Provider_CheckEnabledBox) + ? model.Enabled : originalProviderStatus; + + await _providerService.UpdateAsync(provider); await _applicationCacheService.UpsertProviderAbilityAsync(provider); if (!provider.IsBillable()) @@ -459,7 +469,7 @@ public class ProvidersController : Controller } var organization = model.CreateOrganization(provider); - await _organizationService.CreatePendingOrganization(organization, model.Owners, User, _userService, model.SalesAssistedTrialStarted); + await _resellerClientOrganizationSignUpCommand.SignUpResellerClientAsync(organization, model.Owners); await _providerService.AddOrganization(providerId, organization.Id, null); return RedirectToAction("Edit", "Providers", new { id = providerId }); diff --git a/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs b/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs index b57d90e33b..fd83ba8e5d 100644 --- a/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateBusinessUnitProviderModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; diff --git a/src/Admin/AdminConsole/Models/CreateMspProviderModel.cs b/src/Admin/AdminConsole/Models/CreateMspProviderModel.cs index 4ada2d4a5f..4832910d4c 100644 --- a/src/Admin/AdminConsole/Models/CreateMspProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateMspProviderModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.SharedWeb.Utilities; diff --git a/src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs b/src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs index 958faf3f85..0bb3ea47bb 100644 --- a/src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs +++ b/src/Admin/AdminConsole/Models/CreateResellerProviderModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.SharedWeb.Utilities; diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index c79124688e..b64af3135f 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Net; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; diff --git a/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs b/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs index 5e9055be55..f3d9ae1dd8 100644 --- a/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationInitiateDeleteModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Admin.AdminConsole.Models; diff --git a/src/Admin/AdminConsole/Models/OrganizationUnassignedToProviderSearchViewModel.cs b/src/Admin/AdminConsole/Models/OrganizationUnassignedToProviderSearchViewModel.cs index cbf15a4776..26c27f01b5 100644 --- a/src/Admin/AdminConsole/Models/OrganizationUnassignedToProviderSearchViewModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationUnassignedToProviderSearchViewModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Admin.Models; namespace Bit.Admin.AdminConsole.Models; diff --git a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs index 69486bdcd2..2c126ecd8e 100644 --- a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Entities; using Bit.Core.Enums; @@ -44,6 +47,8 @@ public class OrganizationViewModel orgUsers .Where(u => u.Type == OrganizationUserType.Admin && u.Status == organizationUserStatus) .Select(u => u.Email)); + OwnersDetails = orgUsers.Where(u => u.Type == OrganizationUserType.Owner && u.Status == organizationUserStatus); + AdminsDetails = orgUsers.Where(u => u.Type == OrganizationUserType.Admin && u.Status == organizationUserStatus); SecretsCount = secretsCount; ProjectsCount = projectCount; ServiceAccountsCount = serviceAccountsCount; @@ -70,4 +75,6 @@ public class OrganizationViewModel public int OccupiedSmSeatsCount { get; set; } public bool UseSecretsManager => Organization.UseSecretsManager; public bool UseRiskInsights => Organization.UseRiskInsights; + public IEnumerable OwnersDetails { get; set; } + public IEnumerable AdminsDetails { get; set; } } diff --git a/src/Admin/AdminConsole/Models/OrganizationsModel.cs b/src/Admin/AdminConsole/Models/OrganizationsModel.cs index a98985ef01..6bfec24486 100644 --- a/src/Admin/AdminConsole/Models/OrganizationsModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationsModel.cs @@ -1,4 +1,7 @@ -using Bit.Admin.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Admin.Models; using Bit.Core.AdminConsole.Entities; namespace Bit.Admin.AdminConsole.Models; diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index de9e25fa6f..450dfbb2fc 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; @@ -35,6 +38,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject GatewaySubscriptionUrl = gatewaySubscriptionUrl; Type = provider.Type; PayByInvoice = payByInvoice; + Enabled = provider.Enabled; if (Type == ProviderType.BusinessUnit) { @@ -75,10 +79,14 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject [Display(Name = "Enterprise Seats Minimum")] public int? EnterpriseMinimumSeats { get; set; } + [Display(Name = "Enabled")] + public bool Enabled { get; set; } + public virtual Provider ToProvider(Provider existingProvider) { existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim(); existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim(); + existingProvider.Enabled = Enabled; switch (Type) { case ProviderType.Msp: diff --git a/src/Admin/AdminConsole/Models/ProviderViewModel.cs b/src/Admin/AdminConsole/Models/ProviderViewModel.cs index 2d4ba5012c..f6e16d270d 100644 --- a/src/Admin/AdminConsole/Models/ProviderViewModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderViewModel.cs @@ -1,4 +1,7 @@ -using Bit.Admin.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Admin.Billing.Models; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; @@ -19,7 +22,7 @@ public class ProviderViewModel { Provider = provider; UserCount = providerUsers.Count(); - ProviderAdmins = providerUsers.Where(u => u.Type == ProviderUserType.ProviderAdmin); + ProviderUsers = providerUsers; ProviderOrganizations = organizations.Where(o => o.ProviderId == provider.Id); if (Provider.Type == ProviderType.Msp) @@ -61,7 +64,7 @@ public class ProviderViewModel public int UserCount { get; set; } public Provider Provider { get; set; } - public IEnumerable ProviderAdmins { get; set; } + public IEnumerable ProviderUsers { get; set; } public IEnumerable ProviderOrganizations { get; set; } public List ProviderPlanViewModels { get; set; } = []; } diff --git a/src/Admin/AdminConsole/Models/ProvidersModel.cs b/src/Admin/AdminConsole/Models/ProvidersModel.cs index 6de815facf..ea7b0aa4f0 100644 --- a/src/Admin/AdminConsole/Models/ProvidersModel.cs +++ b/src/Admin/AdminConsole/Models/ProvidersModel.cs @@ -1,4 +1,7 @@ -using Bit.Admin.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Admin.Models; using Bit.Core.AdminConsole.Entities.Provider; namespace Bit.Admin.AdminConsole.Models; diff --git a/src/Admin/AdminConsole/Views/Organizations/Connections.cshtml b/src/Admin/AdminConsole/Views/Organizations/Connections.cshtml index 6efdb34b20..7d2a409715 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Connections.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Connections.cshtml @@ -52,7 +52,7 @@ @if(connection.Enabled) { - @if(@TempData["ConnectionActivated"] != null && @TempData["ConnectionActivated"].ToString() == @Model.Organization.Id.ToString()) + @if(@TempData["ConnectionActivated"] != null && @TempData["ConnectionActivated"]!.ToString() == @Model.Organization.Id.ToString()) { @if(connection.Type.Equals(OrganizationConnectionType.CloudBillingSync)) { diff --git a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index f240cb192f..690ee3d778 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml @@ -1,13 +1,9 @@ @using Bit.Admin.Enums; @using Bit.Admin.Models -@using Bit.Core @using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.Billing.Enums @using Bit.Core.Billing.Extensions -@using Bit.Core.Services -@using Microsoft.AspNetCore.Mvc.TagHelpers @inject Bit.Admin.Services.IAccessControlService AccessControlService -@inject IFeatureService FeatureService @model OrganizationEditModel @{ ViewData["Title"] = (Model.Provider != null ? "Client " : string.Empty) + "Organization: " + Model.Name; @@ -19,12 +15,10 @@ var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete); var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit); - var canConvertToBusinessUnit = - FeatureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion) && - AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) && - Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise && - !string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) && - Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending }; + var canConvertToBusinessUnit = AccessControlService.UserHasPermission(Permission.Org_Billing_ConvertToBusinessUnit) && + Model.Organization.PlanType.GetProductTier() == ProductTierType.Enterprise && + !string.IsNullOrEmpty(Model.Organization.GatewaySubscriptionId) && + Model.Provider is null or { Type: ProviderType.BusinessUnit, Status: ProviderStatusType.Pending }; } @section Scripts { diff --git a/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml b/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml index a0d421235d..9b2f7d69f8 100644 --- a/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml @@ -19,12 +19,6 @@ @Model.UserConfirmedCount) -
Owners
-
@(string.IsNullOrWhiteSpace(Model.Owners) ? "None" : Model.Owners)
- -
Admins
-
@(string.IsNullOrWhiteSpace(Model.Admins) ? "None" : Model.Admins)
-
Using 2FA
@(Model.Organization.TwoFactorIsEnabled() ? "Yes" : "No")
@@ -76,3 +70,49 @@
Secrets Manager Seats
@(Model.UseSecretsManager ? Model.OccupiedSmSeatsCount: "N/A" )
+ +

Administrators

+
+
+
+ + + + + + + + + + @if(!Model.Admins.Any() && !Model.Owners.Any()) + { + + + + } + else + { + @foreach(var owner in Model.OwnersDetails) + { + + + + + + } + + @foreach(var admin in Model.AdminsDetails) + { + + + + + + + } + } + +
EmailRoleStatus
No results to list.
@owner.EmailOwner@owner.Status
@admin.EmailAdmin@admin.Status
+
+
+
diff --git a/src/Admin/AdminConsole/Views/Providers/Admins.cshtml b/src/Admin/AdminConsole/Views/Providers/Admins.cshtml index 86043f3a6d..fb258bec46 100644 --- a/src/Admin/AdminConsole/Views/Providers/Admins.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Admins.cshtml @@ -7,7 +7,7 @@ var canResendEmailInvite = AccessControlService.UserHasPermission(Permission.Provider_ResendEmailInvite); } -

Provider Admins

+

Administrators

@@ -15,12 +15,13 @@ Email + Role Status - @if(!Model.ProviderAdmins.Any()) + @if(!Model.ProviderUsers.Any()) { No results to list. @@ -28,29 +29,39 @@ } else { - @foreach(var admin in Model.ProviderAdmins) + @foreach(var user in Model.ProviderUsers) { - @admin.Email + @user.Email - @admin.Status + @if(@user.Type == 0) + { + Provider Admin + } + else + { + Service User + } + + + @user.Status - @if(admin.Status.Equals(ProviderUserStatusType.Confirmed) + @if(user.Status.Equals(ProviderUserStatusType.Confirmed) && @Model.Provider.Status.Equals(ProviderStatusType.Pending) && canResendEmailInvite) { - @if(@TempData["InviteResentTo"] != null && @TempData["InviteResentTo"].ToString() == @admin.UserId.Value.ToString()) + @if(@TempData["InviteResentTo"] != null && @TempData["InviteResentTo"]!.ToString() == @user.UserId!.Value.ToString()) { } else { Resend Setup Invite diff --git a/src/Admin/AdminConsole/Views/Providers/CreateOrganization.cshtml b/src/Admin/AdminConsole/Views/Providers/CreateOrganization.cshtml index eb790f20ba..148c2e0c2d 100644 --- a/src/Admin/AdminConsole/Views/Providers/CreateOrganization.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/CreateOrganization.cshtml @@ -8,7 +8,7 @@ } diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index d2a9ed1f62..ca4fa70ab5 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -11,6 +11,7 @@ @{ ViewData["Title"] = "Provider: " + Model.Provider.DisplayName(); var canEdit = AccessControlService.UserHasPermission(Permission.Provider_Edit); + var canCheckEnabled = AccessControlService.UserHasPermission(Permission.Provider_CheckEnabledBox); }

Provider @Model.Provider.DisplayName()

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

Business Information

Business Name
diff --git a/src/Admin/AdminConsole/Views/Providers/_ViewInformation.cshtml b/src/Admin/AdminConsole/Views/Providers/_ViewInformation.cshtml index 5d18d7a651..81debddbeb 100644 --- a/src/Admin/AdminConsole/Views/Providers/_ViewInformation.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/_ViewInformation.cshtml @@ -14,6 +14,12 @@
Provider Type
@(Model.Provider.Type.GetDisplayAttribute()?.GetName())
+ @if (Model.Provider.Type == ProviderType.Msp || Model.Provider.Type == ProviderType.BusinessUnit) + { +
Enabled
+
@(Model.Provider.Enabled ? "Yes" : "No")
+ } +
Created
@Model.Provider.CreationDate.ToString()
diff --git a/src/Admin/AdminSettings.cs b/src/Admin/AdminSettings.cs index 18694e3e38..0ecae5c82e 100644 --- a/src/Admin/AdminSettings.cs +++ b/src/Admin/AdminSettings.cs @@ -1,4 +1,7 @@ -namespace Bit.Admin; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Admin; public class AdminSettings { diff --git a/src/Admin/Auth/Controllers/LoginController.cs b/src/Admin/Auth/Controllers/LoginController.cs index dbc04e96c0..7be161e6d9 100644 --- a/src/Admin/Auth/Controllers/LoginController.cs +++ b/src/Admin/Auth/Controllers/LoginController.cs @@ -1,4 +1,7 @@ -using Bit.Admin.Auth.IdentityServer; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Admin.Auth.IdentityServer; using Bit.Admin.Auth.Models; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; diff --git a/src/Admin/Auth/Models/LoginModel.cs b/src/Admin/Auth/Models/LoginModel.cs index 7dd8521a4f..2a1eab0d7c 100644 --- a/src/Admin/Auth/Models/LoginModel.cs +++ b/src/Admin/Auth/Models/LoginModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Admin.Auth.Models; diff --git a/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs b/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs index be3a94949f..9275f41932 100644 --- a/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs +++ b/src/Admin/Billing/Controllers/BusinessUnitConversionController.cs @@ -2,7 +2,6 @@ using Bit.Admin.Billing.Models; using Bit.Admin.Enums; using Bit.Admin.Utilities; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -18,7 +17,6 @@ namespace Bit.Admin.Billing.Controllers; [Authorize] [Route("organizations/billing/{organizationId:guid}/business-unit")] -[RequireFeature(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion)] public class BusinessUnitConversionController( IBusinessUnitConverter businessUnitConverter, IOrganizationRepository organizationRepository, diff --git a/src/Admin/Billing/Controllers/ProcessStripeEventsController.cs b/src/Admin/Billing/Controllers/ProcessStripeEventsController.cs index 1a3f56a183..80ad7fef4e 100644 --- a/src/Admin/Billing/Controllers/ProcessStripeEventsController.cs +++ b/src/Admin/Billing/Controllers/ProcessStripeEventsController.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Admin.Billing.Models.ProcessStripeEvents; using Bit.Core.Settings; using Bit.Core.Utilities; diff --git a/src/Admin/Billing/Models/MigrateProvidersRequestModel.cs b/src/Admin/Billing/Models/MigrateProvidersRequestModel.cs index fe1d88e224..273f934eba 100644 --- a/src/Admin/Billing/Models/MigrateProvidersRequestModel.cs +++ b/src/Admin/Billing/Models/MigrateProvidersRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Admin.Billing.Models; diff --git a/src/Admin/Billing/Models/ProcessStripeEvents/EventsFormModel.cs b/src/Admin/Billing/Models/ProcessStripeEvents/EventsFormModel.cs index 5ead00e263..b78d8cc89e 100644 --- a/src/Admin/Billing/Models/ProcessStripeEvents/EventsFormModel.cs +++ b/src/Admin/Billing/Models/ProcessStripeEvents/EventsFormModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel; using System.ComponentModel.DataAnnotations; namespace Bit.Admin.Billing.Models.ProcessStripeEvents; diff --git a/src/Admin/Billing/Models/ProcessStripeEvents/EventsRequestBody.cs b/src/Admin/Billing/Models/ProcessStripeEvents/EventsRequestBody.cs index 05a2444605..ea9b9c1045 100644 --- a/src/Admin/Billing/Models/ProcessStripeEvents/EventsRequestBody.cs +++ b/src/Admin/Billing/Models/ProcessStripeEvents/EventsRequestBody.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Admin.Billing.Models.ProcessStripeEvents; diff --git a/src/Admin/Billing/Models/ProcessStripeEvents/EventsResponseBody.cs b/src/Admin/Billing/Models/ProcessStripeEvents/EventsResponseBody.cs index 84eeb35d29..5c55b3a8b4 100644 --- a/src/Admin/Billing/Models/ProcessStripeEvents/EventsResponseBody.cs +++ b/src/Admin/Billing/Models/ProcessStripeEvents/EventsResponseBody.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Admin.Billing.Models.ProcessStripeEvents; diff --git a/src/Admin/Controllers/HomeController.cs b/src/Admin/Controllers/HomeController.cs index 20c1be70d0..debe5979f5 100644 --- a/src/Admin/Controllers/HomeController.cs +++ b/src/Admin/Controllers/HomeController.cs @@ -1,4 +1,7 @@ -using System.Diagnostics; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Diagnostics; using System.Text.Json; using Bit.Admin.Models; using Bit.Core.Settings; diff --git a/src/Admin/Controllers/ToolsController.cs b/src/Admin/Controllers/ToolsController.cs index eaf3de4be5..b754b1f968 100644 --- a/src/Admin/Controllers/ToolsController.cs +++ b/src/Admin/Controllers/ToolsController.cs @@ -1,13 +1,16 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text; using System.Text.Json; using Bit.Admin.Enums; using Bit.Admin.Models; using Bit.Admin.Utilities; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Entities; using Bit.Core.Models.BitStripe; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.Services; @@ -24,7 +27,7 @@ public class ToolsController : Controller { private readonly GlobalSettings _globalSettings; private readonly IOrganizationRepository _organizationRepository; - private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; + private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery; private readonly IUserService _userService; private readonly ITransactionRepository _transactionRepository; private readonly IInstallationRepository _installationRepository; @@ -37,7 +40,7 @@ public class ToolsController : Controller public ToolsController( GlobalSettings globalSettings, IOrganizationRepository organizationRepository, - ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, + IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery, IUserService userService, ITransactionRepository transactionRepository, IInstallationRepository installationRepository, @@ -49,7 +52,7 @@ public class ToolsController : Controller { _globalSettings = globalSettings; _organizationRepository = organizationRepository; - _cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery; + _getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery; _userService = userService; _transactionRepository = transactionRepository; _installationRepository = installationRepository; @@ -317,7 +320,7 @@ public class ToolsController : Controller if (organization != null) { - var license = await _cloudGetOrganizationLicenseQuery.GetLicenseAsync(organization, + var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, model.InstallationId.Value, model.Version); var ms = new MemoryStream(); await JsonSerializer.SerializeAsync(ms, license, JsonHelpers.Indented); diff --git a/src/Admin/Dockerfile b/src/Admin/Dockerfile index 79d117681c..648ff1be91 100644 --- a/src/Admin/Dockerfile +++ b/src/Admin/Dockerfile @@ -1,21 +1,75 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +############################################### +# Node.js build stage # +############################################### +FROM node:20-alpine3.21 AS node-build -LABEL com.bitwarden.product="bitwarden" - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - krb5-user \ - && rm -rf /var/lib/apt/lists/* - -ENV ASPNETCORE_URLS http://+:5000 WORKDIR /app -EXPOSE 5000 -COPY obj/build-output/publish . -COPY entrypoint.sh / -RUN chmod +x /entrypoint.sh +COPY src/Admin/package*.json ./ +COPY /src/Admin/ . +RUN npm ci +RUN npm run build -HEALTHCHECK CMD curl -f http://localhost:5000 || exit 1 +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build + +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-musl-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-musl-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-musl-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/src/Admin +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 + +ARG TARGETPLATFORM +LABEL com.bitwarden.product="bitwarden" +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false +EXPOSE 5000 + +RUN apk add --no-cache curl \ + icu-libs \ + tzdata \ + krb5 \ + shadow \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu + +# Copy app from the build stage +WORKDIR /app +COPY --from=build /source/src/Admin/out /app +COPY --from=node-build /app/wwwroot /app/wwwroot +COPY ./src/Admin/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/src/Admin/Enums/Permissions.cs b/src/Admin/Enums/Permissions.cs index 704fd770bb..14b255b2b6 100644 --- a/src/Admin/Enums/Permissions.cs +++ b/src/Admin/Enums/Permissions.cs @@ -45,6 +45,7 @@ public enum Permission Provider_Edit, Provider_View, Provider_ResendEmailInvite, + Provider_CheckEnabledBox, Tools_ChargeBrainTreeCustomer, Tools_PromoteAdmin, diff --git a/src/Admin/HostedServices/AzureQueueMailHostedService.cs b/src/Admin/HostedServices/AzureQueueMailHostedService.cs index cff724e4f3..4669b2b2ec 100644 --- a/src/Admin/HostedServices/AzureQueueMailHostedService.cs +++ b/src/Admin/HostedServices/AzureQueueMailHostedService.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Azure.Storage.Queues; using Azure.Storage.Queues.Models; using Bit.Core.Models.Mail; diff --git a/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs b/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs index 15b8d894b7..ba5c6c0cfd 100644 --- a/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs +++ b/src/Admin/IdentityServer/ReadOnlyEnvIdentityUserStore.cs @@ -1,4 +1,7 @@ -using Bit.Core.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Utilities; using Microsoft.AspNetCore.Identity; namespace Bit.Admin.IdentityServer; @@ -39,7 +42,7 @@ public class ReadOnlyEnvIdentityUserStore : ReadOnlyIdentityUserStore } } - var userStamp = usersDict.ContainsKey(normalizedEmail) ? usersDict[normalizedEmail] : null; + var userStamp = usersDict.GetValueOrDefault(normalizedEmail); if (userStamp == null) { return Task.FromResult(null); diff --git a/src/Admin/IdentityServer/ReadOnlyIdentityUserStore.cs b/src/Admin/IdentityServer/ReadOnlyIdentityUserStore.cs index 88f3a40b1a..4a81745241 100644 --- a/src/Admin/IdentityServer/ReadOnlyIdentityUserStore.cs +++ b/src/Admin/IdentityServer/ReadOnlyIdentityUserStore.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Identity; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Identity; namespace Bit.Admin.IdentityServer; diff --git a/src/Admin/Jobs/DeleteCiphersJob.cs b/src/Admin/Jobs/DeleteCiphersJob.cs index ee48a26d16..b1fc9c53c6 100644 --- a/src/Admin/Jobs/DeleteCiphersJob.cs +++ b/src/Admin/Jobs/DeleteCiphersJob.cs @@ -1,4 +1,7 @@ -using Bit.Core; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core; using Bit.Core.Jobs; using Bit.Core.Vault.Repositories; using Microsoft.Extensions.Options; diff --git a/src/Admin/Models/BillingInformationModel.cs b/src/Admin/Models/BillingInformationModel.cs index ecc06919fa..c6c7ce82c9 100644 --- a/src/Admin/Models/BillingInformationModel.cs +++ b/src/Admin/Models/BillingInformationModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models; namespace Bit.Admin.Models; diff --git a/src/Admin/Models/ChargeBraintreeModel.cs b/src/Admin/Models/ChargeBraintreeModel.cs index 8c2f39e58d..195c0a1f0c 100644 --- a/src/Admin/Models/ChargeBraintreeModel.cs +++ b/src/Admin/Models/ChargeBraintreeModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Admin.Models; diff --git a/src/Admin/Models/CreateUpdateTransactionModel.cs b/src/Admin/Models/CreateUpdateTransactionModel.cs index 8004546f9e..41b7a30413 100644 --- a/src/Admin/Models/CreateUpdateTransactionModel.cs +++ b/src/Admin/Models/CreateUpdateTransactionModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Admin/Models/CursorPagedModel.cs b/src/Admin/Models/CursorPagedModel.cs index 35a4de922a..b6475ad220 100644 --- a/src/Admin/Models/CursorPagedModel.cs +++ b/src/Admin/Models/CursorPagedModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Admin.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Admin.Models; public class CursorPagedModel { diff --git a/src/Admin/Models/ErrorViewModel.cs b/src/Admin/Models/ErrorViewModel.cs index 3b24a1ece7..dc39c2f004 100644 --- a/src/Admin/Models/ErrorViewModel.cs +++ b/src/Admin/Models/ErrorViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Admin.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Admin.Models; public class ErrorViewModel { diff --git a/src/Admin/Models/HomeModel.cs b/src/Admin/Models/HomeModel.cs index 900a04e41a..f4006d6c30 100644 --- a/src/Admin/Models/HomeModel.cs +++ b/src/Admin/Models/HomeModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Settings; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Settings; namespace Bit.Admin.Models; diff --git a/src/Admin/Models/PagedModel.cs b/src/Admin/Models/PagedModel.cs index 4c9c8e1713..3fec874ae5 100644 --- a/src/Admin/Models/PagedModel.cs +++ b/src/Admin/Models/PagedModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Admin.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Admin.Models; public abstract class PagedModel { diff --git a/src/Admin/Models/StripeSubscriptionsModel.cs b/src/Admin/Models/StripeSubscriptionsModel.cs index 99e9c5b77a..36e1f099e1 100644 --- a/src/Admin/Models/StripeSubscriptionsModel.cs +++ b/src/Admin/Models/StripeSubscriptionsModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Models.BitStripe; namespace Bit.Admin.Models; diff --git a/src/Admin/Models/UserEditModel.cs b/src/Admin/Models/UserEditModel.cs index 2597da6e96..cfbb05a5ac 100644 --- a/src/Admin/Models/UserEditModel.cs +++ b/src/Admin/Models/UserEditModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Settings; diff --git a/src/Admin/Models/UserViewModel.cs b/src/Admin/Models/UserViewModel.cs index 7fddbc0f54..719ad7813c 100644 --- a/src/Admin/Models/UserViewModel.cs +++ b/src/Admin/Models/UserViewModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Vault.Entities; diff --git a/src/Admin/Models/UsersModel.cs b/src/Admin/Models/UsersModel.cs index 33148301b2..191a34547d 100644 --- a/src/Admin/Models/UsersModel.cs +++ b/src/Admin/Models/UsersModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Admin.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Admin.Models; public class UsersModel : PagedModel { diff --git a/src/Admin/Program.cs b/src/Admin/Program.cs index fb5dc7e08b..05bf35d41d 100644 --- a/src/Admin/Program.cs +++ b/src/Admin/Program.cs @@ -20,8 +20,8 @@ public class Program logging.AddSerilog(hostingContext, (e, globalSettings) => { var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.ContainsKey("RequestPath") && - !string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && + if (e.Properties.TryGetValue("RequestPath", out var requestPath) && + !string.IsNullOrWhiteSpace(requestPath?.ToString()) && (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) { return false; diff --git a/src/Admin/Services/AccessControlService.cs b/src/Admin/Services/AccessControlService.cs index f45f30e216..f512ec7494 100644 --- a/src/Admin/Services/AccessControlService.cs +++ b/src/Admin/Services/AccessControlService.cs @@ -1,4 +1,7 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Admin.Enums; using Bit.Admin.Utilities; using Bit.Core.Settings; @@ -29,12 +32,12 @@ public class AccessControlService : IAccessControlService } var userRole = GetUserRoleFromClaim(); - if (string.IsNullOrEmpty(userRole) || !RolePermissionMapping.RolePermissions.ContainsKey(userRole)) + if (string.IsNullOrEmpty(userRole) || !RolePermissionMapping.RolePermissions.TryGetValue(userRole, out var rolePermissions)) { return false; } - return RolePermissionMapping.RolePermissions[userRole].Contains(permission); + return rolePermissions.Contains(permission); } public string GetUserRole(string userEmail) diff --git a/src/Admin/TagHelpers/ActivePageTagHelper.cs b/src/Admin/TagHelpers/ActivePageTagHelper.cs index a148e3cdf7..bc8e9afafb 100644 --- a/src/Admin/TagHelpers/ActivePageTagHelper.cs +++ b/src/Admin/TagHelpers/ActivePageTagHelper.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Mvc.Controllers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; diff --git a/src/Admin/Utilities/RolePermissionMapping.cs b/src/Admin/Utilities/RolePermissionMapping.cs index f342dfce7c..b60cf895a1 100644 --- a/src/Admin/Utilities/RolePermissionMapping.cs +++ b/src/Admin/Utilities/RolePermissionMapping.cs @@ -47,6 +47,7 @@ public static class RolePermissionMapping Permission.Provider_Create, Permission.Provider_View, Permission.Provider_ResendEmailInvite, + Permission.Provider_CheckEnabledBox, Permission.Tools_ChargeBrainTreeCustomer, Permission.Tools_PromoteAdmin, Permission.Tools_PromoteProviderServiceUser, @@ -98,6 +99,7 @@ public static class RolePermissionMapping Permission.Provider_View, Permission.Provider_Edit, Permission.Provider_ResendEmailInvite, + Permission.Provider_CheckEnabledBox, Permission.Tools_ChargeBrainTreeCustomer, Permission.Tools_PromoteAdmin, Permission.Tools_PromoteProviderServiceUser, @@ -135,7 +137,8 @@ public static class RolePermissionMapping Permission.Org_Billing_LaunchGateway, Permission.Org_RequestDelete, Permission.Provider_List_View, - Permission.Provider_View + Permission.Provider_View, + Permission.Provider_CheckEnabledBox } }, { "billing", new List @@ -173,6 +176,7 @@ public static class RolePermissionMapping Permission.Provider_Edit, Permission.Provider_View, Permission.Provider_List_View, + Permission.Provider_CheckEnabledBox, Permission.Tools_ChargeBrainTreeCustomer, Permission.Tools_GenerateLicenseFile, Permission.Tools_ManageTaxRates, diff --git a/src/Admin/Views/Tools/ChargeBraintree.cshtml b/src/Admin/Views/Tools/ChargeBraintree.cshtml index aaf3bbf167..0c661a8ee4 100644 --- a/src/Admin/Views/Tools/ChargeBraintree.cshtml +++ b/src/Admin/Views/Tools/ChargeBraintree.cshtml @@ -8,7 +8,7 @@ @if(!string.IsNullOrWhiteSpace(Model.TransactionId)) { diff --git a/src/Admin/entrypoint.sh b/src/Admin/entrypoint.sh index 2c564b1ce6..21bb61716c 100644 --- a/src/Admin/entrypoint.sh +++ b/src/Admin/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # Setup @@ -19,31 +19,36 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -chown -R $USERNAME:$GROUPNAME /app -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then + chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos + fi + + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi -if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then - chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos - cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf - gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab +if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then + cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf + $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Admin.dll +exec $gosu_cmd /app/Admin diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index e73ccfcef5..b3f19c4792 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -18,7 +18,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.88.0", + "sass": "1.89.2", "sass-loader": "16.0.5", "webpack": "5.99.8", "webpack-cli": "5.1.4" @@ -1861,9 +1861,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.88.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz", - "integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==", + "version": "1.89.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", + "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Admin/package.json b/src/Admin/package.json index e88cd42eca..9076a46239 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -17,7 +17,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.88.0", + "sass": "1.89.2", "sass-loader": "16.0.5", "webpack": "5.99.8", "webpack-cli": "5.1.4" diff --git a/src/Api/.dockerignore b/src/Api/.dockerignore deleted file mode 100644 index fc12f25146..0000000000 --- a/src/Api/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!obj/build-output/publish/* -!obj/Docker/empty/ -!entrypoint.sh diff --git a/src/Api/AdminConsole/Authorization/Requirements/BasePermissionRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/BasePermissionRequirement.cs new file mode 100644 index 0000000000..e904080043 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Requirements/BasePermissionRequirement.cs @@ -0,0 +1,24 @@ +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Data; + +namespace Bit.Api.AdminConsole.Authorization.Requirements; + +/// +/// A base implementation of which will authorize Owners, Admins, Providers, +/// and custom users with the permission specified by the permissionPicker constructor parameter. This is suitable +/// for most requirements related to a custom permission. +/// +/// A function that returns a custom permission which will authorize the action. +public abstract class BasePermissionRequirement(Func permissionPicker) : IOrganizationRequirement +{ + public async Task AuthorizeAsync(CurrentContextOrganization? organizationClaims, + Func> isProviderUserForOrg) + => organizationClaims switch + { + { Type: OrganizationUserType.Owner } => true, + { Type: OrganizationUserType.Admin } => true, + { Type: OrganizationUserType.Custom } when permissionPicker(organizationClaims.Permissions) => true, + _ => await isProviderUserForOrg() + }; +} diff --git a/src/Api/AdminConsole/Authorization/Requirements/ManageAccountRecoveryRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/ManageAccountRecoveryRequirement.cs deleted file mode 100644 index 268fee5d95..0000000000 --- a/src/Api/AdminConsole/Authorization/Requirements/ManageAccountRecoveryRequirement.cs +++ /dev/null @@ -1,20 +0,0 @@ -#nullable enable - -using Bit.Core.Context; -using Bit.Core.Enums; - -namespace Bit.Api.AdminConsole.Authorization.Requirements; - -public class ManageAccountRecoveryRequirement : IOrganizationRequirement -{ - public async Task AuthorizeAsync( - CurrentContextOrganization? organizationClaims, - Func> isProviderUserForOrg) - => organizationClaims switch - { - { Type: OrganizationUserType.Owner } => true, - { Type: OrganizationUserType.Admin } => true, - { Permissions.ManageResetPassword: true } => true, - _ => await isProviderUserForOrg() - }; -} diff --git a/src/Api/AdminConsole/Authorization/Requirements/ManageGroupsOrUsersRequirement.cs b/src/Api/AdminConsole/Authorization/Requirements/ManageGroupsOrUsersRequirement.cs new file mode 100644 index 0000000000..daa5c025cb --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Requirements/ManageGroupsOrUsersRequirement.cs @@ -0,0 +1,17 @@ +using Bit.Core.Context; +using Bit.Core.Enums; + +namespace Bit.Api.AdminConsole.Authorization.Requirements; + +public class ManageGroupsOrUsersRequirement : IOrganizationRequirement +{ + public async Task AuthorizeAsync(CurrentContextOrganization? organizationClaims, Func> isProviderUserForOrg) => + organizationClaims switch + { + { Type: OrganizationUserType.Owner } => true, + { Type: OrganizationUserType.Admin } => true, + { Permissions.ManageGroups: true } => true, + { Permissions.ManageUsers: true } => true, + _ => await isProviderUserForOrg() + }; +} diff --git a/src/Api/AdminConsole/Authorization/Requirements/PermissionRequirements.cs b/src/Api/AdminConsole/Authorization/Requirements/PermissionRequirements.cs new file mode 100644 index 0000000000..e3100aff11 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/Requirements/PermissionRequirements.cs @@ -0,0 +1,11 @@ +namespace Bit.Api.AdminConsole.Authorization.Requirements; + +public class AccessEventLogsRequirement() : BasePermissionRequirement(p => p.AccessEventLogs); +public class AccessImportExportRequirement() : BasePermissionRequirement(p => p.AccessImportExport); +public class AccessReportsRequirement() : BasePermissionRequirement(p => p.AccessReports); +public class ManageAccountRecoveryRequirement() : BasePermissionRequirement(p => p.ManageResetPassword); +public class ManageGroupsRequirement() : BasePermissionRequirement(p => p.ManageGroups); +public class ManagePoliciesRequirement() : BasePermissionRequirement(p => p.ManagePolicies); +public class ManageScimRequirement() : BasePermissionRequirement(p => p.ManageScim); +public class ManageSsoRequirement() : BasePermissionRequirement(p => p.ManageSso); +public class ManageUsersRequirement() : BasePermissionRequirement(p => p.ManageUsers); diff --git a/src/Api/AdminConsole/Controllers/EventsController.cs b/src/Api/AdminConsole/Controllers/EventsController.cs index 921ee84400..d555c7321d 100644 --- a/src/Api/AdminConsole/Controllers/EventsController.cs +++ b/src/Api/AdminConsole/Controllers/EventsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Context; diff --git a/src/Api/AdminConsole/Controllers/GroupsController.cs b/src/Api/AdminConsole/Controllers/GroupsController.cs index 946d7399c2..f8e97881cb 100644 --- a/src/Api/AdminConsole/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Controllers/GroupsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response; using Bit.Api.Models.Response; using Bit.Api.Vault.AuthorizationHandlers.Collections; diff --git a/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs b/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs index 8e54bfca9c..79ed2ceabe 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs @@ -1,14 +1,17 @@ -using Bit.Api.AdminConsole.Models.Request.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs index 848098ef00..319fbbe707 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs @@ -18,6 +18,27 @@ public class OrganizationIntegrationConfigurationController( IOrganizationIntegrationRepository integrationRepository, IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller { + [HttpGet("")] + public async Task> GetAsync( + Guid organizationId, + Guid integrationId) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var configurations = await integrationConfigurationRepository.GetManyByIntegrationAsync(integrationId); + return configurations + .Select(configuration => new OrganizationIntegrationConfigurationResponseModel(configuration)) + .ToList(); + } + [HttpPost("")] public async Task CreateAsync( Guid organizationId, diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs index 3b52e7a8da..7052350c9a 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs @@ -19,6 +19,20 @@ public class OrganizationIntegrationController( ICurrentContext currentContext, IOrganizationIntegrationRepository integrationRepository) : Controller { + [HttpGet("")] + public async Task> GetAsync(Guid organizationId) + { + if (!await HasPermission(organizationId)) + { + throw new NotFoundException(); + } + + var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId); + return integrations + .Select(integration => new OrganizationIntegrationResponseModel(integration)) + .ToList(); + } + [HttpPost("")] public async Task CreateAsync(Guid organizationId, [FromBody] OrganizationIntegrationRequestModel model) { diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 6b23edf347..2b464c24e2 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -1,4 +1,8 @@ -using Bit.Api.AdminConsole.Authorization.Requirements; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Request.Organizations; @@ -7,16 +11,14 @@ using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Enums; @@ -55,19 +57,19 @@ public class OrganizationUsersController : Controller private readonly IApplicationCacheService _applicationCacheService; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; - private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand; private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; + private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; + private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand; - public OrganizationUsersController( - IOrganizationRepository organizationRepository, + public OrganizationUsersController(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationService organizationService, ICollectionRepository collectionRepository, @@ -83,7 +85,6 @@ public class OrganizationUsersController : Controller IApplicationCacheService applicationCacheService, ISsoConfigRepository ssoConfigRepository, IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, - ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, @@ -92,7 +93,9 @@ public class OrganizationUsersController : Controller IPricingClient pricingClient, IConfirmOrganizationUserCommand confirmOrganizationUserCommand, IRestoreOrganizationUserCommand restoreOrganizationUserCommand, - IInitPendingOrganizationCommand initPendingOrganizationCommand) + IInitPendingOrganizationCommand initPendingOrganizationCommand, + IRevokeOrganizationUserCommand revokeOrganizationUserCommand, + IResendOrganizationInviteCommand resendOrganizationInviteCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -110,23 +113,25 @@ public class OrganizationUsersController : Controller _applicationCacheService = applicationCacheService; _ssoConfigRepository = ssoConfigRepository; _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; - _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; _deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand; _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; _pricingClient = pricingClient; + _resendOrganizationInviteCommand = resendOrganizationInviteCommand; _confirmOrganizationUserCommand = confirmOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; _initPendingOrganizationCommand = initPendingOrganizationCommand; + _revokeOrganizationUserCommand = revokeOrganizationUserCommand; } [HttpGet("{id}")] - public async Task Get(Guid id, bool includeGroups = false) + [Authorize] + public async Task Get(Guid orgId, Guid id, bool includeGroups = false) { var (organizationUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id); - if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.OrganizationId)) + if (organizationUser == null || organizationUser.OrganizationId != orgId) { throw new NotFoundException(); } @@ -145,16 +150,17 @@ public class OrganizationUsersController : Controller return response; } + /// + /// Returns a set of basic information about all members of the organization. This is available to all members of + /// the organization to manage collections. For this reason, it contains as little information as possible and no + /// cryptographic keys or other sensitive data. + /// + /// Organization identifier + /// List of users for the organization. [HttpGet("mini-details")] + [Authorize] public async Task> GetMiniDetails(Guid orgId) { - var authorizationResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), - OrganizationUserUserMiniDetailsOperations.ReadAll); - if (!authorizationResult.Succeeded) - { - throw new NotFoundException(); - } - var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(orgId); return new ListResponseModel( organizationUserUserDetails.Select(ou => new OrganizationUserUserMiniDetailsResponseModel(ou))); @@ -162,49 +168,12 @@ public class OrganizationUsersController : Controller [HttpGet("")] public async Task> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false) - { - - if (_featureService.IsEnabled(FeatureFlagKeys.SeparateCustomRolePermissions)) - { - return await GetvNextAsync(orgId, includeGroups, includeCollections); - } - - var authorized = (await _authorizationService.AuthorizeAsync( - User, new OrganizationScope(orgId), OrganizationUserUserDetailsOperations.ReadAll)).Succeeded; - if (!authorized) - { - throw new NotFoundException(); - } - - var organizationUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails( - new OrganizationUserUserDetailsQueryRequest - { - OrganizationId = orgId, - IncludeGroups = includeGroups, - IncludeCollections = includeCollections - } - ); - var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); - var organizationUsersClaimedStatus = await GetClaimedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id)); - var responses = organizationUsers - .Select(o => - { - var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled; - var claimedByOrganization = organizationUsersClaimedStatus[o.Id]; - var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, claimedByOrganization); - - return orgUser; - }); - return new ListResponseModel(responses); - } - - private async Task> GetvNextAsync(Guid orgId, bool includeGroups = false, bool includeCollections = false) { var request = new OrganizationUserUserDetailsQueryRequest { OrganizationId = orgId, IncludeGroups = includeGroups, - IncludeCollections = includeCollections, + IncludeCollections = includeCollections }; if ((await _authorizationService.AuthorizeAsync(User, new ManageUsersRequirement())).Succeeded) @@ -228,34 +197,12 @@ public class OrganizationUsersController : Controller .ToList()); } - - [HttpGet("{id}/groups")] - public async Task> GetGroups(string orgId, string id) - { - var organizationUser = await _organizationUserRepository.GetByIdAsync(new Guid(id)); - if (organizationUser == null || (!await _currentContext.ManageGroups(organizationUser.OrganizationId) && - !await _currentContext.ManageUsers(organizationUser.OrganizationId))) - { - throw new NotFoundException(); - } - - var groupIds = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Id); - var responses = groupIds.Select(g => g.ToString()); - return responses; - } - [HttpGet("{id}/reset-password-details")] - public async Task GetResetPasswordDetails(string orgId, string id) + [Authorize] + public async Task GetResetPasswordDetails(Guid orgId, Guid id) { - // Make sure the calling user can reset passwords for this org - var orgGuidId = new Guid(orgId); - if (!await _currentContext.ManageResetPassword(orgGuidId)) - { - throw new NotFoundException(); - } - - var organizationUser = await _organizationUserRepository.GetByIdAsync(new Guid(id)); - if (organizationUser == null || !organizationUser.UserId.HasValue) + var organizationUser = await _organizationUserRepository.GetByIdAsync(id); + if (organizationUser is null || organizationUser.UserId is null) { throw new NotFoundException(); } @@ -269,7 +216,7 @@ public class OrganizationUsersController : Controller } // Retrieve Encrypted Private Key from organization - var org = await _organizationRepository.GetByIdAsync(orgGuidId); + var org = await _organizationRepository.GetByIdAsync(orgId); if (org == null) { throw new NotFoundException(); @@ -279,26 +226,17 @@ public class OrganizationUsersController : Controller } [HttpPost("account-recovery-details")] + [Authorize] public async Task> GetAccountRecoveryDetails(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - // Make sure the calling user can reset passwords for this org - if (!await _currentContext.ManageResetPassword(orgId)) - { - throw new NotFoundException(); - } - var responses = await _organizationUserRepository.GetManyAccountRecoveryDetailsByOrganizationUserAsync(orgId, model.Ids); return new ListResponseModel(responses.Select(r => new OrganizationUserResetPasswordDetailsResponseModel(r))); } [HttpPost("invite")] + [Authorize] public async Task Invite(Guid orgId, [FromBody] OrganizationUserInviteRequestModel model) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - // Check the user has permission to grant access to the collections for the new user if (model.Collections?.Any() == true) { @@ -314,35 +252,25 @@ public class OrganizationUsersController : Controller var userId = _userService.GetProperUserId(User); await _organizationService.InviteUsersAsync(orgId, userId.Value, systemUser: null, - new (OrganizationUserInvite, string)[] { (new OrganizationUserInvite(model.ToData()), null) }); + [(new OrganizationUserInvite(model.ToData()), null)]); } [HttpPost("reinvite")] - public async Task> BulkReinvite(string orgId, [FromBody] OrganizationUserBulkRequestModel model) + [Authorize] + public async Task> BulkReinvite(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - var orgGuidId = new Guid(orgId); - if (!await _currentContext.ManageUsers(orgGuidId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); - var result = await _organizationService.ResendInvitesAsync(orgGuidId, userId.Value, model.Ids); + var result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids); return new ListResponseModel( result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2))); } [HttpPost("{id}/reinvite")] - public async Task Reinvite(string orgId, string id) + [Authorize] + public async Task Reinvite(Guid orgId, Guid id) { - var orgGuidId = new Guid(orgId); - if (!await _currentContext.ManageUsers(orgGuidId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); - await _organizationService.ResendInviteAsync(orgGuidId, userId.Value, new Guid(id)); + await _resendOrganizationInviteCommand.ResendInviteAsync(orgId, userId.Value, id); } [HttpPost("{organizationUserId}/accept-init")] @@ -403,58 +331,39 @@ public class OrganizationUsersController : Controller } [HttpPost("{id}/confirm")] - public async Task Confirm(string orgId, string id, [FromBody] OrganizationUserConfirmRequestModel model) + [Authorize] + public async Task Confirm(Guid orgId, Guid id, [FromBody] OrganizationUserConfirmRequestModel model) { - var orgGuidId = new Guid(orgId); - if (!await _currentContext.ManageUsers(orgGuidId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); - var result = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgGuidId, new Guid(id), model.Key, userId.Value); + _ = await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, id, model.Key, userId.Value, model.DefaultUserCollectionName); } [HttpPost("confirm")] - public async Task> BulkConfirm(string orgId, + [Authorize] + public async Task> BulkConfirm(Guid orgId, [FromBody] OrganizationUserBulkConfirmRequestModel model) { - var orgGuidId = new Guid(orgId); - if (!await _currentContext.ManageUsers(orgGuidId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); - var results = await _confirmOrganizationUserCommand.ConfirmUsersAsync(orgGuidId, model.ToDictionary(), userId.Value); + var results = await _confirmOrganizationUserCommand.ConfirmUsersAsync(orgId, model.ToDictionary(), userId.Value, model.DefaultUserCollectionName); return new ListResponseModel(results.Select(r => new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); } [HttpPost("public-keys")] - public async Task> UserPublicKeys(string orgId, [FromBody] OrganizationUserBulkRequestModel model) + [Authorize] + public async Task> UserPublicKeys(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - var orgGuidId = new Guid(orgId); - if (!await _currentContext.ManageUsers(orgGuidId)) - { - throw new NotFoundException(); - } - - var result = await _organizationUserRepository.GetManyPublicKeysByOrganizationUserAsync(orgGuidId, model.Ids); + var result = await _organizationUserRepository.GetManyPublicKeysByOrganizationUserAsync(orgId, model.Ids); var responses = result.Select(r => new OrganizationUserPublicKeyResponseModel(r.Id, r.UserId, r.PublicKey)).ToList(); return new ListResponseModel(responses); } [HttpPut("{id}")] [HttpPost("{id}")] + [Authorize] public async Task Put(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var (organizationUser, currentAccess) = await _organizationUserRepository.GetByIdWithCollectionsAsync(id); if (organizationUser == null || organizationUser.OrganizationId != orgId) { @@ -521,7 +430,9 @@ public class OrganizationUsersController : Controller .Concat(readonlyCollectionAccess) .ToList(); - await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), userId, + var existingUserType = organizationUser.Type; + + await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), existingUserType, userId, collectionsToSave, groupsToSave); } @@ -553,27 +464,19 @@ public class OrganizationUsersController : Controller } [HttpPut("{id}/reset-password")] - public async Task PutResetPassword(string orgId, string id, [FromBody] OrganizationUserResetPasswordRequestModel model) + [Authorize] + public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) { - - var orgGuidId = new Guid(orgId); - - // Calling user must have Manage Reset Password permission - if (!await _currentContext.ManageResetPassword(orgGuidId)) - { - throw new NotFoundException(); - } - // Get the users role, since provider users aren't a member of the organization we use the owner check - var orgUserType = await _currentContext.OrganizationOwner(orgGuidId) + var orgUserType = await _currentContext.OrganizationOwner(orgId) ? OrganizationUserType.Owner - : _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgGuidId)?.Type; + : _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type; if (orgUserType == null) { throw new NotFoundException(); } - var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgGuidId, new Guid(id), model.NewMasterPasswordHash, model.Key); + var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key); if (result.Succeeded) { return; @@ -590,26 +493,18 @@ public class OrganizationUsersController : Controller [HttpDelete("{id}")] [HttpPost("{id}/remove")] + [Authorize] public async Task Remove(Guid orgId, Guid id) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); await _removeOrganizationUserCommand.RemoveUserAsync(orgId, id, userId.Value); } [HttpDelete("")] [HttpPost("remove")] + [Authorize] public async Task> BulkRemove(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); var result = await _removeOrganizationUserCommand.RemoveUsersAsync(orgId, model.Ids, userId.Value); return new ListResponseModel(result.Select(r => @@ -618,13 +513,9 @@ public class OrganizationUsersController : Controller [HttpDelete("{id}/delete-account")] [HttpPost("{id}/delete-account")] + [Authorize] public async Task DeleteAccount(Guid orgId, Guid id) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var currentUser = await _userService.GetUserByPrincipalAsync(User); if (currentUser == null) { @@ -636,13 +527,9 @@ public class OrganizationUsersController : Controller [HttpDelete("delete-account")] [HttpPost("delete-account")] + [Authorize] public async Task> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var currentUser = await _userService.GetUserByPrincipalAsync(User); if (currentUser == null) { @@ -657,20 +544,23 @@ public class OrganizationUsersController : Controller [HttpPatch("{id}/revoke")] [HttpPut("{id}/revoke")] + [Authorize] public async Task RevokeAsync(Guid orgId, Guid id) { - await RestoreOrRevokeUserAsync(orgId, id, _organizationService.RevokeUserAsync); + await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync); } [HttpPatch("revoke")] [HttpPut("revoke")] + [Authorize] public async Task> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - return await RestoreOrRevokeUsersAsync(orgId, model, _organizationService.RevokeUsersAsync); + return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); } [HttpPatch("{id}/restore")] [HttpPut("{id}/restore")] + [Authorize] public async Task RestoreAsync(Guid orgId, Guid id) { await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId)); @@ -678,6 +568,7 @@ public class OrganizationUsersController : Controller [HttpPatch("restore")] [HttpPut("restore")] + [Authorize] public async Task> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); @@ -685,14 +576,10 @@ public class OrganizationUsersController : Controller [HttpPatch("enable-secrets-manager")] [HttpPut("enable-secrets-manager")] + [Authorize] public async Task BulkEnableSecretsManagerAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var orgUsers = (await _organizationUserRepository.GetManyAsync(model.Ids)) .Where(ou => ou.OrganizationId == orgId && !ou.AccessSecretsManager).ToList(); if (orgUsers.Count == 0) @@ -725,11 +612,6 @@ public class OrganizationUsersController : Controller Guid id, Func statusAction) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); var orgUser = await _organizationUserRepository.GetByIdAsync(id); if (orgUser == null || orgUser.OrganizationId != orgId) @@ -745,11 +627,6 @@ public class OrganizationUsersController : Controller OrganizationUserBulkRequestModel model, Func, Guid?, Task>>> statusAction) { - if (!await _currentContext.ManageUsers(orgId)) - { - throw new NotFoundException(); - } - var userId = _userService.GetProperUserId(User); var result = await statusAction(orgId, model.Ids, userId.Value); return new ListResponseModel(result.Select(r => diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 0d498beab1..18045178db 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response; using Bit.Api.AdminConsole.Models.Response.Organizations; diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 86a1609ee6..a80546e2f5 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response.Helpers; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; diff --git a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs index f226ba316e..caf2651e16 100644 --- a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Billing.Controllers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Billing.Controllers; using Bit.Api.Billing.Models.Requests; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; diff --git a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs index 12166c836e..f68b036be4 100644 --- a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Request.Providers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request.Providers; using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Providers.Interfaces; diff --git a/src/Api/AdminConsole/Controllers/ProviderUsersController.cs b/src/Api/AdminConsole/Controllers/ProviderUsersController.cs index 73639bb1a4..b89f553325 100644 --- a/src/Api/AdminConsole/Controllers/ProviderUsersController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderUsersController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Request.Providers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request.Providers; using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Models.Business.Provider; diff --git a/src/Api/AdminConsole/Controllers/ProvidersController.cs b/src/Api/AdminConsole/Controllers/ProvidersController.cs index b6933da0c9..d8bda2ca18 100644 --- a/src/Api/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Api/AdminConsole/Controllers/ProvidersController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Request.Providers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request.Providers; using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; diff --git a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs index c0ab5c059b..6e3751c6f6 100644 --- a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs @@ -1,8 +1,11 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Core; using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Models.Data.Integrations; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/src/Api/AdminConsole/Jobs/OrganizationSubscriptionUpdateJob.cs b/src/Api/AdminConsole/Jobs/OrganizationSubscriptionUpdateJob.cs new file mode 100644 index 0000000000..3a6dbb22f4 --- /dev/null +++ b/src/Api/AdminConsole/Jobs/OrganizationSubscriptionUpdateJob.cs @@ -0,0 +1,35 @@ +using System.Collections.Immutable; +using Bit.Core; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Jobs; +using Bit.Core.Services; +using Quartz; + +namespace Bit.Api.AdminConsole.Jobs; + +public class OrganizationSubscriptionUpdateJob(ILogger logger, + IGetOrganizationSubscriptionsToUpdateQuery query, + IUpdateOrganizationSubscriptionCommand command, + IFeatureService featureService) : BaseJob(logger) +{ + protected override async Task ExecuteJobAsync(IJobExecutionContext _) + { + if (!featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization)) + { + return; + } + + logger.LogInformation("OrganizationSubscriptionUpdateJob - START"); + + var organizationSubscriptionsToUpdate = + (await query.GetOrganizationSubscriptionsToUpdateAsync()) + .ToImmutableList(); + + logger.LogInformation("OrganizationSubscriptionUpdateJob - {numberOfOrganizations} organization(s) to update", + organizationSubscriptionsToUpdate.Count); + + await command.UpdateOrganizationSubscriptionAsync(organizationSubscriptionsToUpdate); + + logger.LogInformation("OrganizationSubscriptionUpdateJob - COMPLETED"); + } +} diff --git a/src/Api/AdminConsole/Models/Request/AdminAuthRequestUpdateRequestModel.cs b/src/Api/AdminConsole/Models/Request/AdminAuthRequestUpdateRequestModel.cs index abcc6fdb74..13c840ced4 100644 --- a/src/Api/AdminConsole/Models/Request/AdminAuthRequestUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/AdminAuthRequestUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request; diff --git a/src/Api/AdminConsole/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs b/src/Api/AdminConsole/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs index 24386341a3..86e058b847 100644 --- a/src/Api/AdminConsole/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.AdminConsole.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.AdminConsole.Models.Request; public class BulkDenyAdminAuthRequestRequestModel { diff --git a/src/Api/AdminConsole/Models/Request/GroupRequestModel.cs b/src/Api/AdminConsole/Models/Request/GroupRequestModel.cs index a6cfb6733b..007b3d3949 100644 --- a/src/Api/AdminConsole/Models/Request/GroupRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/GroupRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Models.Request; using Bit.Core.AdminConsole.Entities; diff --git a/src/Api/AdminConsole/Models/Request/OrganizationAuthRequestUpdateManyRequestModel.cs b/src/Api/AdminConsole/Models/Request/OrganizationAuthRequestUpdateManyRequestModel.cs index 34a45369b2..bd5e647c84 100644 --- a/src/Api/AdminConsole/Models/Request/OrganizationAuthRequestUpdateManyRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/OrganizationAuthRequestUpdateManyRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationAuth.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationAuth.Models; using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request; diff --git a/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs b/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs index 8bf1ebe39a..46b253da31 100644 --- a/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/OrganizationDomainRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Models.Request; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationConnectionRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationConnectionRequestModel.cs index d7508b78ef..1dbd624cbe 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationConnectionRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationConnectionRequestModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationConnections; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs index e18122fd2b..10f938adfe 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Bit.Core.Billing.Enums; using Bit.Core.Entities; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationDomainSsoDetailsRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationDomainSsoDetailsRequestModel.cs index c5129c6ec7..b4eafc095f 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationDomainSsoDetailsRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationDomainSsoDetailsRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Models.Request.Organizations; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs index ccab2b36ae..17e116b8d1 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs @@ -1,10 +1,10 @@ -using System.ComponentModel.DataAnnotations; +#nullable enable + using System.Text.Json; using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Models.Data.Integrations; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; -#nullable enable namespace Bit.Api.AdminConsole.Models.Request.Organizations; @@ -12,8 +12,9 @@ public class OrganizationIntegrationConfigurationRequestModel { public string? Configuration { get; set; } - [Required] - public EventType EventType { get; set; } + public EventType? EventType { get; set; } + + public string? Filters { get; set; } public string? Template { get; set; } @@ -24,9 +25,17 @@ public class OrganizationIntegrationConfigurationRequestModel case IntegrationType.CloudBillingSync or IntegrationType.Scim: return false; case IntegrationType.Slack: - return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid(); + return !string.IsNullOrWhiteSpace(Template) && + IsConfigurationValid() && + IsFiltersValid(); case IntegrationType.Webhook: - return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid(); + return !string.IsNullOrWhiteSpace(Template) && + IsConfigurationValid() && + IsFiltersValid(); + case IntegrationType.Hec: + return !string.IsNullOrWhiteSpace(Template) && + Configuration is null && + IsFiltersValid(); default: return false; @@ -39,6 +48,7 @@ public class OrganizationIntegrationConfigurationRequestModel { OrganizationIntegrationId = organizationIntegrationId, Configuration = Configuration, + Filters = Filters, EventType = EventType, Template = Template }; @@ -48,6 +58,7 @@ public class OrganizationIntegrationConfigurationRequestModel { currentConfiguration.Configuration = Configuration; currentConfiguration.EventType = EventType; + currentConfiguration.Filters = Filters; currentConfiguration.Template = Template; return currentConfiguration; @@ -70,4 +81,22 @@ public class OrganizationIntegrationConfigurationRequestModel return false; } } + + private bool IsFiltersValid() + { + if (Filters is null) + { + return true; + } + + try + { + var filters = JsonSerializer.Deserialize(Filters); + return filters is not null; + } + catch + { + return false; + } + } } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationKeysRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationKeysRequestModel.cs index 4afa5b54ea..22b225a689 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationKeysRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationKeysRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Business; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs index 3255c8b413..0c62b23518 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Bit.Core.Billing.Enums; using Bit.Core.Entities; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs index decc04a0db..5a3192c121 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Data; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs index 2a73f094ed..a5dec192b9 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpgradeRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Enums; using Bit.Core.Models.Business; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs index bbbb571f42..4e0accb9e8 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Models.Request; using Bit.Core.Entities; using Bit.Core.Enums; @@ -60,6 +63,10 @@ public class OrganizationUserConfirmRequestModel { [Required] public string Key { get; set; } + + [EncryptedString] + [EncryptedStringLength(1000)] + public string DefaultUserCollectionName { get; set; } } public class OrganizationUserBulkConfirmRequestModelEntry @@ -75,6 +82,10 @@ public class OrganizationUserBulkConfirmRequestModel [Required] public IEnumerable Keys { get; set; } + [EncryptedString] + [EncryptedStringLength(1000)] + public string DefaultUserCollectionName { get; set; } + public Dictionary ToDictionary() { return Keys.ToDictionary(e => e.Id, e => e.Key); diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs index 36dba6ed98..0963887994 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationVerifyDeleteRecoverRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Models.Request.Organizations; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs index 1a5c110254..edae0719e3 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; #nullable enable @@ -39,10 +41,22 @@ public class OrganizationIntegrationRequestModel : IValidatableObject yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", new[] { nameof(Type) }); break; case IntegrationType.Webhook: - if (Configuration is not null) + if (string.IsNullOrWhiteSpace(Configuration)) + { + break; + } + if (!IsIntegrationValid()) { yield return new ValidationResult( - "Webhook integrations must not include configuration.", + "Webhook integrations must include valid configuration.", + new[] { nameof(Configuration) }); + } + break; + case IntegrationType.Hec: + if (!IsIntegrationValid()) + { + yield return new ValidationResult( + "HEC integrations must include valid configuration.", new[] { nameof(Configuration) }); } break; @@ -53,4 +67,22 @@ public class OrganizationIntegrationRequestModel : IValidatableObject break; } } + + private bool IsIntegrationValid() + { + if (string.IsNullOrWhiteSpace(Configuration)) + { + return false; + } + + try + { + var config = JsonSerializer.Deserialize(Configuration); + return config is not null; + } + catch + { + return false; + } + } } diff --git a/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs b/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs index a243f46b2e..0e31deacd1 100644 --- a/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationAddRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationAddRequestModel.cs index 207d84b787..9a33431443 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationAddRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationAddRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Models.Request.Providers; diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationCreateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationCreateRequestModel.cs index bf75c611e2..25417d04c5 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationCreateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderOrganizationCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Core.Utilities; diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs index 697077c9b6..1f50c384a3 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Bit.Api.Billing.Models.Requests; using Bit.Api.Models.Request; diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderUpdateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderUpdateRequestModel.cs index e41cb13f4e..8a7ab7643b 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Settings; diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderUserRequestModels.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderUserRequestModels.cs index dd22530916..12b1e0d064 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderUserRequestModels.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderUserRequestModels.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Utilities; diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderVerifyDeleteRecoverRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderVerifyDeleteRecoverRequestModel.cs index edb58c21b1..a3a0f4fba6 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderVerifyDeleteRecoverRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderVerifyDeleteRecoverRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Models.Request.Providers; diff --git a/src/Api/AdminConsole/Models/Response/GroupResponseModel.cs b/src/Api/AdminConsole/Models/Response/GroupResponseModel.cs index f956f27ebc..741473a5c4 100644 --- a/src/Api/AdminConsole/Models/Response/GroupResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/GroupResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Api; using Bit.Core.Models.Data; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationConnectionResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationConnectionResponseModel.cs index fa6bdc1f3d..f365080b73 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationConnectionResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationConnectionResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs index 8d074509c5..c7906318e8 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs @@ -17,12 +17,14 @@ public class OrganizationIntegrationConfigurationResponseModel : ResponseModel Configuration = organizationIntegrationConfiguration.Configuration; CreationDate = organizationIntegrationConfiguration.CreationDate; EventType = organizationIntegrationConfiguration.EventType; + Filters = organizationIntegrationConfiguration.Filters; Template = organizationIntegrationConfiguration.Template; } public Guid Id { get; set; } public string? Configuration { get; set; } + public string? Filters { get; set; } public DateTime CreationDate { get; set; } - public EventType EventType { get; set; } + public EventType? EventType { get; set; } public string? Template { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs index cc6e778528..f062ff46a2 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs @@ -15,8 +15,10 @@ public class OrganizationIntegrationResponseModel : ResponseModel Id = organizationIntegration.Id; Type = organizationIntegration.Type; + Configuration = organizationIntegration.Configuration; } public Guid Id { get; set; } public IntegrationType Type { get; set; } + public string? Configuration { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationKeysResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationKeysResponseModel.cs index 15dbb18102..1b82d20fb8 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationKeysResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationKeysResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Api; namespace Bit.Api.AdminConsole.Models.Response.Organizations; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationPublicKeyResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationPublicKeyResponseModel.cs index defae9ba4d..b938fd9893 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationPublicKeyResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationPublicKeyResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Api; namespace Bit.Api.AdminConsole.Models.Response.Organizations; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 95754598b9..b34765fb19 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -1,7 +1,11 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Models.Api; using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs index 057841c7d2..7c31c2ae81 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Api.Models.Response; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs index 86e62a4193..9feafce70c 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.Models.Api; diff --git a/src/Api/AdminConsole/Models/Response/Organizations/VerifiedOrganizationDomainSsoDetailsResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/VerifiedOrganizationDomainSsoDetailsResponseModel.cs index 3488eab2c8..178060d9b1 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/VerifiedOrganizationDomainSsoDetailsResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/VerifiedOrganizationDomainSsoDetailsResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; namespace Bit.Api.AdminConsole.Models.Response.Organizations; diff --git a/src/Api/AdminConsole/Models/Response/PendingOrganizationAuthRequestResponseModel.cs b/src/Api/AdminConsole/Models/Response/PendingOrganizationAuthRequestResponseModel.cs index 3e242cba7b..8952270adf 100644 --- a/src/Api/AdminConsole/Models/Response/PendingOrganizationAuthRequestResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/PendingOrganizationAuthRequestResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Reflection; using Bit.Core.Auth.Models.Data; using Bit.Core.Models.Api; diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index cb0ab62fd1..e421c3247e 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; diff --git a/src/Api/AdminConsole/Models/Response/Providers/ProviderOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Providers/ProviderOrganizationResponseModel.cs index 963fbaa209..c0b492df95 100644 --- a/src/Api/AdminConsole/Models/Response/Providers/ProviderOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Providers/ProviderOrganizationResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.Models.Api; diff --git a/src/Api/AdminConsole/Models/Response/Providers/ProviderResponseModel.cs b/src/Api/AdminConsole/Models/Response/Providers/ProviderResponseModel.cs index 291fb24829..5031c4963d 100644 --- a/src/Api/AdminConsole/Models/Response/Providers/ProviderResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Providers/ProviderResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Models.Api; diff --git a/src/Api/AdminConsole/Public/Controllers/EventsController.cs b/src/Api/AdminConsole/Public/Controllers/EventsController.cs index 992b7453aa..3dd55d51e2 100644 --- a/src/Api/AdminConsole/Public/Controllers/EventsController.cs +++ b/src/Api/AdminConsole/Public/Controllers/EventsController.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.Models.Public.Request; using Bit.Api.Models.Public.Response; using Bit.Core.Context; diff --git a/src/Api/AdminConsole/Public/Controllers/GroupsController.cs b/src/Api/AdminConsole/Public/Controllers/GroupsController.cs index 9ce22536b1..9644d2d799 100644 --- a/src/Api/AdminConsole/Public/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Public/Controllers/GroupsController.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 6552684ca3..7bfe5648b6 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -1,8 +1,12 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; @@ -29,6 +33,7 @@ public class MembersController : Controller private readonly IOrganizationRepository _organizationRepository; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; public MembersController( IOrganizationUserRepository organizationUserRepository, @@ -42,7 +47,8 @@ public class MembersController : Controller IPaymentService paymentService, IOrganizationRepository organizationRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IRemoveOrganizationUserCommand removeOrganizationUserCommand) + IRemoveOrganizationUserCommand removeOrganizationUserCommand, + IResendOrganizationInviteCommand resendOrganizationInviteCommand) { _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; @@ -56,6 +62,7 @@ public class MembersController : Controller _organizationRepository = organizationRepository; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; + _resendOrganizationInviteCommand = resendOrganizationInviteCommand; } /// @@ -177,9 +184,10 @@ public class MembersController : Controller { return new NotFoundResult(); } + var existingUserType = existingUser.Type; var updatedUser = model.ToOrganizationUser(existingUser); var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList(); - await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, null, associations, model.Groups); + await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, existingUserType, null, associations, model.Groups); MemberResponseModel response = null; if (existingUser.UserId.HasValue) { @@ -256,7 +264,7 @@ public class MembersController : Controller { return new NotFoundResult(); } - await _organizationService.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id); + await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id); return new OkResult(); } } diff --git a/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs b/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs index c1715f471c..18afa10ac0 100644 --- a/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs +++ b/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs @@ -1,8 +1,12 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.Models.Public.Response; +using Bit.Core; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Context; -using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Settings; @@ -18,15 +22,21 @@ public class OrganizationController : Controller private readonly IOrganizationService _organizationService; private readonly ICurrentContext _currentContext; private readonly GlobalSettings _globalSettings; + private readonly IImportOrganizationUsersAndGroupsCommand _importOrganizationUsersAndGroupsCommand; + private readonly IFeatureService _featureService; public OrganizationController( IOrganizationService organizationService, ICurrentContext currentContext, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + IImportOrganizationUsersAndGroupsCommand importOrganizationUsersAndGroupsCommand, + IFeatureService featureService) { _organizationService = organizationService; _currentContext = currentContext; _globalSettings = globalSettings; + _importOrganizationUsersAndGroupsCommand = importOrganizationUsersAndGroupsCommand; + _featureService = featureService; } /// @@ -47,13 +57,26 @@ public class OrganizationController : Controller throw new BadRequestException("You cannot import this much data at once."); } - await _organizationService.ImportAsync( - _currentContext.OrganizationId.Value, - model.Groups.Select(g => g.ToImportedGroup(_currentContext.OrganizationId.Value)), - model.Members.Where(u => !u.Deleted).Select(u => u.ToImportedOrganizationUser()), - model.Members.Where(u => u.Deleted).Select(u => u.ExternalId), - model.OverwriteExisting.GetValueOrDefault(), - EventSystemUser.PublicApi); + if (_featureService.IsEnabled(FeatureFlagKeys.ImportAsyncRefactor)) + { + await _importOrganizationUsersAndGroupsCommand.ImportAsync( + _currentContext.OrganizationId.Value, + model.Groups.Select(g => g.ToImportedGroup(_currentContext.OrganizationId.Value)), + model.Members.Where(u => !u.Deleted).Select(u => u.ToImportedOrganizationUser()), + model.Members.Where(u => u.Deleted).Select(u => u.ExternalId), + model.OverwriteExisting.GetValueOrDefault()); + } + else + { + await _organizationService.ImportAsync( + _currentContext.OrganizationId.Value, + model.Groups.Select(g => g.ToImportedGroup(_currentContext.OrganizationId.Value)), + model.Members.Where(u => !u.Deleted).Select(u => u.ToImportedOrganizationUser()), + model.Members.Where(u => u.Deleted).Select(u => u.ExternalId), + model.OverwriteExisting.GetValueOrDefault(), + Core.Enums.EventSystemUser.PublicApi); + } + return new OkResult(); } } diff --git a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs index d261a3c555..1caf9cb068 100644 --- a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; diff --git a/src/Api/AdminConsole/Public/Models/GroupBaseModel.cs b/src/Api/AdminConsole/Public/Models/GroupBaseModel.cs index fd42cccffd..d21e9d757f 100644 --- a/src/Api/AdminConsole/Public/Models/GroupBaseModel.cs +++ b/src/Api/AdminConsole/Public/Models/GroupBaseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Public.Models; diff --git a/src/Api/AdminConsole/Public/Models/PolicyBaseModel.cs b/src/Api/AdminConsole/Public/Models/PolicyBaseModel.cs index f474d87ec9..ba455d92e1 100644 --- a/src/Api/AdminConsole/Public/Models/PolicyBaseModel.cs +++ b/src/Api/AdminConsole/Public/Models/PolicyBaseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.AdminConsole.Public.Models; diff --git a/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs index 202bd5f705..7a76526ede 100644 --- a/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/AssociationWithPermissionsRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Data; namespace Bit.Api.AdminConsole.Public.Models.Request; diff --git a/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs index 852076eebc..2d96425d55 100644 --- a/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Exceptions; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Exceptions; namespace Bit.Api.Models.Public.Request; diff --git a/src/Api/AdminConsole/Public/Models/Request/GroupCreateUpdateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/GroupCreateUpdateRequestModel.cs index 671503c649..3c531b4208 100644 --- a/src/Api/AdminConsole/Public/Models/Request/GroupCreateUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/GroupCreateUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; namespace Bit.Api.AdminConsole.Public.Models.Request; diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs index f6b2c4d4af..6813610325 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs index ac281e3c44..674fa1290f 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Api/AdminConsole/Public/Models/Request/OrganizationImportRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/OrganizationImportRequestModel.cs index 2adda81e49..6122d5dfd0 100644 --- a/src/Api/AdminConsole/Public/Models/Request/OrganizationImportRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/OrganizationImportRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; diff --git a/src/Api/AdminConsole/Public/Models/Request/UpdateGroupIdsRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/UpdateGroupIdsRequestModel.cs index c55be36fff..f3714025ac 100644 --- a/src/Api/AdminConsole/Public/Models/Request/UpdateGroupIdsRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/UpdateGroupIdsRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.AdminConsole.Public.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.AdminConsole.Public.Models.Request; public class UpdateGroupIdsRequestModel { diff --git a/src/Api/AdminConsole/Public/Models/Request/UpdateMemberIdsRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/UpdateMemberIdsRequestModel.cs index 4124719929..bf0ea342ac 100644 --- a/src/Api/AdminConsole/Public/Models/Request/UpdateMemberIdsRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/UpdateMemberIdsRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.AdminConsole.Public.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.AdminConsole.Public.Models.Request; public class UpdateMemberIdsRequestModel { diff --git a/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs index c275d1658b..c12616b4cc 100644 --- a/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/GroupResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Data; diff --git a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs index 933cda9dca..70da584621 100644 --- a/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/MemberResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using Bit.Api.Models.Public.Response; diff --git a/src/Api/AdminConsole/Public/Models/Response/PolicyResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/PolicyResponseModel.cs index 8da7d93cf1..e43f994255 100644 --- a/src/Api/AdminConsole/Public/Models/Response/PolicyResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/PolicyResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index c490e90150..11af4d5e0a 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -34,7 +34,7 @@ - + diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 2499b269f5..f197f1270b 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -1,34 +1,25 @@ -using Bit.Api.AdminConsole.Models.Request.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + using Bit.Api.AdminConsole.Models.Response; -using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.Accounts; -using Bit.Api.Auth.Models.Request.WebAuthn; -using Bit.Api.KeyManagement.Validators; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Response; -using Bit.Api.Tools.Models.Request; -using Bit.Api.Vault.Models.Request; using Bit.Core; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; -using Bit.Core.Auth.Entities; 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.Models.Data; -using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Api.Response; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Entities; using Bit.Core.Utilities; -using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -45,20 +36,9 @@ public class AccountsController : Controller private readonly IPolicyService _policyService; private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; - private readonly IRotateUserKeyCommand _rotateUserKeyCommand; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IFeatureService _featureService; - - private readonly IRotationValidator, IEnumerable> _cipherValidator; - private readonly IRotationValidator, IEnumerable> _folderValidator; - private readonly IRotationValidator, IReadOnlyList> _sendValidator; - private readonly IRotationValidator, IEnumerable> - _emergencyAccessValidator; - private readonly IRotationValidator, - IReadOnlyList> - _organizationUserValidator; - private readonly IRotationValidator, IEnumerable> - _webauthnKeyValidator; + private readonly ITwoFactorEmailService _twoFactorEmailService; public AccountsController( @@ -69,17 +49,9 @@ public class AccountsController : Controller IPolicyService policyService, ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand, ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, - IRotateUserKeyCommand rotateUserKeyCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IFeatureService featureService, - IRotationValidator, IEnumerable> cipherValidator, - IRotationValidator, IEnumerable> folderValidator, - IRotationValidator, IReadOnlyList> sendValidator, - IRotationValidator, IEnumerable> - emergencyAccessValidator, - IRotationValidator, IReadOnlyList> - organizationUserValidator, - IRotationValidator, IEnumerable> webAuthnKeyValidator + ITwoFactorEmailService twoFactorEmailService ) { _organizationService = organizationService; @@ -89,15 +61,10 @@ public class AccountsController : Controller _policyService = policyService; _setInitialMasterPasswordCommand = setInitialMasterPasswordCommand; _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; - _rotateUserKeyCommand = rotateUserKeyCommand; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _featureService = featureService; - _cipherValidator = cipherValidator; - _folderValidator = folderValidator; - _sendValidator = sendValidator; - _emergencyAccessValidator = emergencyAccessValidator; - _organizationUserValidator = organizationUserValidator; - _webauthnKeyValidator = webAuthnKeyValidator; + _twoFactorEmailService = twoFactorEmailService; + } @@ -313,45 +280,6 @@ public class AccountsController : Controller throw new BadRequestException(ModelState); } - [Obsolete("Replaced by the safer rotate-user-account-keys endpoint.")] - [HttpPost("key")] - public async Task PostKey([FromBody] UpdateKeyRequestModel model) - { - var user = await _userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var dataModel = new RotateUserKeyData - { - MasterPasswordHash = model.MasterPasswordHash, - Key = model.Key, - PrivateKey = model.PrivateKey, - Ciphers = await _cipherValidator.ValidateAsync(user, model.Ciphers), - Folders = await _folderValidator.ValidateAsync(user, model.Folders), - Sends = await _sendValidator.ValidateAsync(user, model.Sends), - EmergencyAccesses = await _emergencyAccessValidator.ValidateAsync(user, model.EmergencyAccessKeys), - OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.ResetPasswordKeys), - WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.WebAuthnKeys) - }; - - var result = await _rotateUserKeyCommand.RotateUserKeyAsync(user, dataModel); - - if (result.Succeeded) - { - return; - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - await Task.Delay(2000); - throw new BadRequestException(ModelState); - } - [HttpPost("security-stamp")] public async Task PostSecurityStamp([FromBody] SecretVerificationRequestModel model) { @@ -700,7 +628,14 @@ public class AccountsController : Controller [HttpPost("resend-new-device-otp")] public async Task ResendNewDeviceOtpAsync([FromBody] UnauthenticatedSecretVerificationRequestModel request) { - await _userService.ResendNewDeviceVerificationEmail(request.Email, request.Secret); + var user = await _userService.GetUserByPrincipalAsync(User) ?? throw new UnauthorizedAccessException(); + if (!await _userService.VerifySecretAsync(user, request.Secret)) + { + await Task.Delay(2000); + throw new BadRequestException(string.Empty, "User verification failed."); + } + + await _twoFactorEmailService.SendNewDeviceVerificationEmailAsync(user); } [HttpPost("verify-devices")] diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index f7edc7dec4..3f91bd6eea 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -1,5 +1,9 @@ -using Bit.Api.Auth.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; +using Bit.Core; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.AuthRequest; using Bit.Core.Auth.Services; @@ -7,6 +11,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -14,31 +19,23 @@ namespace Bit.Api.Auth.Controllers; [Route("auth-requests")] [Authorize("Application")] -public class AuthRequestsController : Controller +public class AuthRequestsController( + IUserService userService, + IAuthRequestRepository authRequestRepository, + IGlobalSettings globalSettings, + IAuthRequestService authRequestService) : Controller { - private readonly IUserService _userService; - private readonly IAuthRequestRepository _authRequestRepository; - private readonly IGlobalSettings _globalSettings; - private readonly IAuthRequestService _authRequestService; - - public AuthRequestsController( - IUserService userService, - IAuthRequestRepository authRequestRepository, - IGlobalSettings globalSettings, - IAuthRequestService authRequestService) - { - _userService = userService; - _authRequestRepository = authRequestRepository; - _globalSettings = globalSettings; - _authRequestService = authRequestService; - } + private readonly IUserService _userService = userService; + private readonly IAuthRequestRepository _authRequestRepository = authRequestRepository; + private readonly IGlobalSettings _globalSettings = globalSettings; + private readonly IAuthRequestService _authRequestService = authRequestService; [HttpGet("")] public async Task> Get() { var userId = _userService.GetProperUserId(User).Value; var authRequests = await _authRequestRepository.GetManyByUserIdAsync(userId); - var responses = authRequests.Select(a => new AuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault)).ToList(); + var responses = authRequests.Select(a => new AuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault)); return new ListResponseModel(responses); } @@ -56,6 +53,16 @@ public class AuthRequestsController : Controller return new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault); } + [HttpGet("pending")] + [RequireFeature(FeatureFlagKeys.BrowserExtensionLoginApproval)] + public async Task> GetPendingAuthRequestsAsync() + { + var userId = _userService.GetProperUserId(User).Value; + var rawResponse = await _authRequestRepository.GetManyPendingAuthRequestByUserId(userId); + var responses = rawResponse.Select(a => new PendingAuthRequestResponseModel(a, _globalSettings.BaseServiceUri.Vault)); + return new ListResponseModel(responses); + } + [HttpGet("{id}/response")] [AllowAnonymous] public async Task GetResponse(Guid id, [FromQuery] string code) diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 5d1f47de73..53b57fe685 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -1,10 +1,12 @@ -using Bit.Api.AdminConsole.Models.Request.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Response; -using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Services; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -72,7 +74,7 @@ public class EmergencyAccessController : Controller { var user = await _userService.GetUserByPrincipalAsync(User); var policies = await _emergencyAccessService.GetPoliciesAsync(id, user); - var responses = policies.Select(policy => new PolicyResponseModel(policy)); + var responses = policies?.Select(policy => new PolicyResponseModel(policy)); return new ListResponseModel(responses); } diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 83490f1c2f..96b64f16fc 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Auth.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Response.TwoFactor; using Bit.Api.Models.Request; @@ -7,6 +10,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; @@ -34,6 +38,7 @@ public class TwoFactorController : Controller private readonly IDuoUniversalTokenService _duoUniversalTokenService; private readonly IDataProtectorTokenFactory _twoFactorAuthenticatorDataProtector; private readonly IDataProtectorTokenFactory _ssoEmailTwoFactorSessionDataProtector; + private readonly ITwoFactorEmailService _twoFactorEmailService; public TwoFactorController( IUserService userService, @@ -44,7 +49,8 @@ public class TwoFactorController : Controller IVerifyAuthRequestCommand verifyAuthRequestCommand, IDuoUniversalTokenService duoUniversalConfigService, IDataProtectorTokenFactory twoFactorAuthenticatorDataProtector, - IDataProtectorTokenFactory ssoEmailTwoFactorSessionDataProtector) + IDataProtectorTokenFactory ssoEmailTwoFactorSessionDataProtector, + ITwoFactorEmailService twoFactorEmailService) { _userService = userService; _organizationRepository = organizationRepository; @@ -55,6 +61,7 @@ public class TwoFactorController : Controller _duoUniversalTokenService = duoUniversalConfigService; _twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector; _ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector; + _twoFactorEmailService = twoFactorEmailService; } [HttpGet("")] @@ -297,8 +304,9 @@ public class TwoFactorController : Controller public async Task SendEmail([FromBody] TwoFactorEmailRequestModel model) { var user = await CheckAsync(model, false, true); + // Add email to the user's 2FA providers, with the email address they've provided. model.ToUser(user); - await _userService.SendTwoFactorEmailAsync(user, false); + await _twoFactorEmailService.SendTwoFactorSetupEmailAsync(user); } [AllowAnonymous] @@ -316,15 +324,14 @@ public class TwoFactorController : Controller .VerifyAuthRequestAsync(new Guid(requestModel.AuthRequestId), requestModel.AuthRequestAccessCode)) { - await _userService.SendTwoFactorEmailAsync(user); - return; + await _twoFactorEmailService.SendTwoFactorEmailAsync(user); } } else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken)) { if (ValidateSsoEmail2FaToken(requestModel.SsoEmail2FaSessionToken, user)) { - await _userService.SendTwoFactorEmailAsync(user); + await _twoFactorEmailService.SendTwoFactorEmailAsync(user); return; } @@ -333,7 +340,7 @@ public class TwoFactorController : Controller } else if (await _userService.VerifySecretAsync(user, requestModel.Secret)) { - await _userService.SendTwoFactorEmailAsync(user); + await _twoFactorEmailService.SendTwoFactorEmailAsync(user); return; } } diff --git a/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs b/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs index 4ab1f24287..c67cb9db3f 100644 --- a/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs +++ b/src/Api/Auth/Jobs/EmergencyAccessNotificationJob.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Services; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Services; using Bit.Core.Jobs; using Quartz; diff --git a/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs b/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs index b125e4f057..f23774f060 100644 --- a/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs +++ b/src/Api/Auth/Jobs/EmergencyAccessTimeoutJob.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Services; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Services; using Bit.Core.Jobs; using Quartz; diff --git a/src/Api/Auth/Models/Request/Accounts/DeleteRecoverRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/DeleteRecoverRequestModel.cs index f4326ee6b6..a87836eff9 100644 --- a/src/Api/Auth/Models/Request/Accounts/DeleteRecoverRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/DeleteRecoverRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/EmailRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/EmailRequestModel.cs index 8d45ec41b3..de90b3e83e 100644 --- a/src/Api/Auth/Models/Request/Accounts/EmailRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/EmailRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/EmailTokenRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/EmailTokenRequestModel.cs index bd75b65a5e..ec5f4a27e1 100644 --- a/src/Api/Auth/Models/Request/Accounts/EmailTokenRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/EmailTokenRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordHintRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordHintRequestModel.cs index a52b7b5163..1f2bccd1ce 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordHintRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordHintRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index ce197c4aad..01da1f0f9f 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/RegenerateTwoFactorRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/RegenerateTwoFactorRequestModel.cs index dbbcdf7331..e59001c203 100644 --- a/src/Api/Auth/Models/Request/Accounts/RegenerateTwoFactorRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/RegenerateTwoFactorRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs index c0191728f4..7e4ce98fa2 100644 --- a/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs index b07c7ea81f..0d809c6c11 100644 --- a/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Api/Auth/Models/Request/Accounts/UnauthenticatedSecretVerificationRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UnauthenticatedSecretVerificationRequestModel.cs index abd37023c8..bf0cbd76ec 100644 --- a/src/Api/Auth/Models/Request/Accounts/UnauthenticatedSecretVerificationRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UnauthenticatedSecretVerificationRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateKeyRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateKeyRequestModel.cs index d3cb5c2442..ad35d98750 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateKeyRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateKeyRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Tools.Models.Request; diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateProfileRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateProfileRequestModel.cs index 76072cb3a4..29ac9c5df9 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateProfileRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateProfileRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs index e246a99c96..e99c990756 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs index d2b8dce727..e071726edf 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Models.Request.Organizations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/VerifyDeleteRecoverRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/VerifyDeleteRecoverRequestModel.cs index 495cd0bdb5..3decedb14d 100644 --- a/src/Api/Auth/Models/Request/Accounts/VerifyDeleteRecoverRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/VerifyDeleteRecoverRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/VerifyEmailRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/VerifyEmailRequestModel.cs index 730e3ee3be..8d086781d9 100644 --- a/src/Api/Auth/Models/Request/Accounts/VerifyEmailRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/VerifyEmailRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/Accounts/VerifyOTPRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/VerifyOTPRequestModel.cs index 1db68acc99..edfa3ce2b2 100644 --- a/src/Api/Auth/Models/Request/Accounts/VerifyOTPRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/VerifyOTPRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Auth.Models.Request.Accounts; diff --git a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index 8b1d5e883b..33a7e52791 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Utilities; diff --git a/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs b/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs index d82b26aa26..fcf386d7ee 100644 --- a/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs +++ b/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; diff --git a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs index 357db5ad1e..79df29c928 100644 --- a/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs +++ b/src/Api/Auth/Models/Request/TwoFactorRequestModels.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; @@ -25,7 +28,7 @@ public class UpdateTwoFactorAuthenticatorRequestModel : SecretVerificationReques { providers = new Dictionary(); } - else if (providers.ContainsKey(TwoFactorProviderType.Authenticator)) + else { providers.Remove(TwoFactorProviderType.Authenticator); } @@ -62,7 +65,7 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV { providers = []; } - else if (providers.ContainsKey(TwoFactorProviderType.Duo)) + else { providers.Remove(TwoFactorProviderType.Duo); } @@ -88,7 +91,7 @@ public class UpdateTwoFactorDuoRequestModel : SecretVerificationRequestModel, IV { providers = []; } - else if (providers.ContainsKey(TwoFactorProviderType.OrganizationDuo)) + else { providers.Remove(TwoFactorProviderType.OrganizationDuo); } @@ -145,7 +148,7 @@ public class UpdateTwoFactorYubicoOtpRequestModel : SecretVerificationRequestMod { providers = new Dictionary(); } - else if (providers.ContainsKey(TwoFactorProviderType.YubiKey)) + else { providers.Remove(TwoFactorProviderType.YubiKey); } @@ -228,7 +231,7 @@ public class TwoFactorEmailRequestModel : SecretVerificationRequestModel { providers = new Dictionary(); } - else if (providers.ContainsKey(TwoFactorProviderType.Email)) + else { providers.Remove(TwoFactorProviderType.Email); } diff --git a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialCreatelRequestModel.cs b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialCreatelRequestModel.cs index 8c6acbc8d4..c73bd94292 100644 --- a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialCreatelRequestModel.cs +++ b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialCreatelRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Fido2NetLib; diff --git a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialUpdateRequestModel.cs b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialUpdateRequestModel.cs index 54244c2dbd..aaae88bd49 100644 --- a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialUpdateRequestModel.cs +++ b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginCredentialUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Fido2NetLib; diff --git a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginRotateKeyRequestModel.cs b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginRotateKeyRequestModel.cs index 7e161cfbea..ec4f2b1724 100644 --- a/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginRotateKeyRequestModel.cs +++ b/src/Api/Auth/Models/Request/WebAuthn/WebAuthnLoginRotateKeyRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Models.Data; using Bit.Core.Utilities; diff --git a/src/Api/Auth/Models/Response/AuthRequestResponseModel.cs b/src/Api/Auth/Models/Response/AuthRequestResponseModel.cs index 7a9734d844..82aa38c9ac 100644 --- a/src/Api/Auth/Models/Response/AuthRequestResponseModel.cs +++ b/src/Api/Auth/Models/Response/AuthRequestResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Reflection; using Bit.Core.Auth.Entities; using Bit.Core.Enums; diff --git a/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs b/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs index 2fb9a67199..640c9bb3e0 100644 --- a/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs +++ b/src/Api/Auth/Models/Response/EmergencyAccessResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Vault.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Vault.Models.Response; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; @@ -90,6 +93,13 @@ public class EmergencyAccessGrantorDetailsResponseModel : EmergencyAccessRespons public class EmergencyAccessTakeoverResponseModel : ResponseModel { + /// + /// Creates a new instance of the class. + /// + /// Consumed for the Encrypted Key value + /// consumed for the KDF configuration + /// name of the object + /// emergencyAccess cannot be null public EmergencyAccessTakeoverResponseModel(EmergencyAccess emergencyAccess, User grantor, string obj = "emergencyAccessTakeover") : base(obj) { if (emergencyAccess == null) diff --git a/src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs b/src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs index 0d327e1009..a8930bc9eb 100644 --- a/src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs +++ b/src/Api/Auth/Models/Response/OrganizationSsoResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Models.Api; diff --git a/src/Api/Auth/Models/Response/PendingAuthRequestResponseModel.cs b/src/Api/Auth/Models/Response/PendingAuthRequestResponseModel.cs new file mode 100644 index 0000000000..8428593068 --- /dev/null +++ b/src/Api/Auth/Models/Response/PendingAuthRequestResponseModel.cs @@ -0,0 +1,15 @@ +using Bit.Core.Auth.Models.Data; + +namespace Bit.Api.Auth.Models.Response; + +public class PendingAuthRequestResponseModel : AuthRequestResponseModel +{ + public PendingAuthRequestResponseModel(PendingAuthRequestDetails authRequest, string vaultUri, string obj = "auth-request") + : base(authRequest, vaultUri, obj) + { + ArgumentNullException.ThrowIfNull(authRequest); + RequestDeviceId = authRequest.RequestDeviceId; + } + + public Guid? RequestDeviceId { get; set; } +} diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs index f791c6fb1e..47cf49c439 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorAuthenticatorResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Models.Api; using OtpNet; @@ -13,9 +16,9 @@ public class TwoFactorAuthenticatorResponseModel : ResponseModel ArgumentNullException.ThrowIfNull(user); var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Authenticator); - if (provider?.MetaData?.ContainsKey("Key") ?? false) + if (provider?.MetaData?.TryGetValue("Key", out var keyValue) ?? false) { - Key = (string)provider.MetaData["Key"]; + Key = (string)keyValue; Enabled = provider.Enabled; } else diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs index 79012783a4..e7e29d06cb 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorDuoResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Entities; diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs index ee1797f83e..e16f2a6b78 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorEmailResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Models.Api; @@ -15,9 +18,9 @@ public class TwoFactorEmailResponseModel : ResponseModel } var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); - if (provider?.MetaData?.ContainsKey("Email") ?? false) + if (provider?.MetaData?.TryGetValue("Email", out var email) ?? false) { - Email = (string)provider.MetaData["Email"]; + Email = (string)email; Enabled = provider.Enabled; } else diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorRecoverResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorRecoverResponseModel.cs index 0022633973..2369c0ea1c 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorRecoverResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorRecoverResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Models.Api; namespace Bit.Api.Auth.Models.Response.TwoFactor; diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs index 2e1d1aa050..cd853e5739 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorWebAuthnResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Entities; using Bit.Core.Models.Api; diff --git a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs index 014863497d..10cc6749e6 100644 --- a/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs +++ b/src/Api/Auth/Models/Response/TwoFactor/TwoFactorYubiKeyResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Models.Api; @@ -19,29 +22,29 @@ public class TwoFactorYubiKeyResponseModel : ResponseModel { Enabled = provider.Enabled; - if (provider.MetaData.ContainsKey("Key1")) + if (provider.MetaData.TryGetValue("Key1", out var key1)) { - Key1 = (string)provider.MetaData["Key1"]; + Key1 = (string)key1; } - if (provider.MetaData.ContainsKey("Key2")) + if (provider.MetaData.TryGetValue("Key2", out var key2)) { - Key2 = (string)provider.MetaData["Key2"]; + Key2 = (string)key2; } - if (provider.MetaData.ContainsKey("Key3")) + if (provider.MetaData.TryGetValue("Key3", out var key3)) { - Key3 = (string)provider.MetaData["Key3"]; + Key3 = (string)key3; } - if (provider.MetaData.ContainsKey("Key4")) + if (provider.MetaData.TryGetValue("Key4", out var key4)) { - Key4 = (string)provider.MetaData["Key4"]; + Key4 = (string)key4; } - if (provider.MetaData.ContainsKey("Key5")) + if (provider.MetaData.TryGetValue("Key5", out var key5)) { - Key5 = (string)provider.MetaData["Key5"]; + Key5 = (string)key5; } - if (provider.MetaData.ContainsKey("Nfc")) + if (provider.MetaData.TryGetValue("Nfc", out var nfc)) { - Nfc = (bool)provider.MetaData["Nfc"]; + Nfc = (bool)nfc; } } else diff --git a/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs b/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs index d521bdac96..517785e6e4 100644 --- a/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs +++ b/src/Api/Auth/Models/Response/WebAuthn/WebAuthnCredentialCreateOptionsResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Fido2NetLib; namespace Bit.Api.Auth.Models.Response.WebAuthn; diff --git a/src/Api/Billing/Attributes/InjectOrganizationAttribute.cs b/src/Api/Billing/Attributes/InjectOrganizationAttribute.cs new file mode 100644 index 0000000000..f4c2a8c637 --- /dev/null +++ b/src/Api/Billing/Attributes/InjectOrganizationAttribute.cs @@ -0,0 +1,61 @@ +#nullable enable +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Models.Api; +using Bit.Core.Repositories; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Bit.Api.Billing.Attributes; + +/// +/// An action filter that facilitates the injection of a parameter into the executing action method arguments. +/// +/// +/// This attribute retrieves the organization associated with the 'organizationId' included in the executing context's route data. If the organization cannot be found, +/// the request is terminated with a not found response. +/// The injected +/// parameter must be marked with a [BindNever] attribute to short-circuit the model-binding system. +/// +/// +/// EndpointAsync([BindNever] Organization organization) +/// ]]> +/// +/// +public class InjectOrganizationAttribute : ActionFilterAttribute +{ + public override async Task OnActionExecutionAsync( + ActionExecutingContext context, + ActionExecutionDelegate next) + { + if (!context.RouteData.Values.TryGetValue("organizationId", out var routeValue) || + !Guid.TryParse(routeValue?.ToString(), out var organizationId)) + { + context.Result = new BadRequestObjectResult(new ErrorResponseModel("Route parameter 'organizationId' is missing or invalid.")); + return; + } + + var organizationRepository = context.HttpContext.RequestServices + .GetRequiredService(); + + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + context.Result = new NotFoundObjectResult(new ErrorResponseModel("Organization not found.")); + return; + } + + var organizationParameter = context.ActionDescriptor.Parameters + .FirstOrDefault(p => p.ParameterType == typeof(Organization)); + + if (organizationParameter != null) + { + context.ActionArguments[organizationParameter.Name] = organization; + } + + await next(); + } +} diff --git a/src/Api/Billing/Attributes/InjectProviderAttribute.cs b/src/Api/Billing/Attributes/InjectProviderAttribute.cs new file mode 100644 index 0000000000..e65dda37c3 --- /dev/null +++ b/src/Api/Billing/Attributes/InjectProviderAttribute.cs @@ -0,0 +1,80 @@ +#nullable enable +using Bit.Api.Models.Public.Response; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Bit.Api.Billing.Attributes; + +/// +/// An action filter that facilitates the injection of a parameter into the executing action method arguments after performing an authorization check. +/// +/// +/// This attribute retrieves the provider associated with the 'providerId' included in the executing context's route data. If the provider cannot be found, +/// the request is terminated with a not-found response. It then checks the authorization level for the provider using the provided . +/// If this check fails, the request is terminated with an unauthorized response. +/// The injected +/// parameter must be marked with a [BindNever] attribute to short-circuit the model-binding system. +/// +/// +/// EndpointAsync([BindNever] Provider provider) +/// ]]> +/// +/// The desired access level for the authorization check. +/// +public class InjectProviderAttribute(ProviderUserType providerUserType) : ActionFilterAttribute +{ + public override async Task OnActionExecutionAsync( + ActionExecutingContext context, + ActionExecutionDelegate next) + { + if (!context.RouteData.Values.TryGetValue("providerId", out var routeValue) || + !Guid.TryParse(routeValue?.ToString(), out var providerId)) + { + context.Result = new BadRequestObjectResult(new ErrorResponseModel("Route parameter 'providerId' is missing or invalid.")); + return; + } + + var providerRepository = context.HttpContext.RequestServices + .GetRequiredService(); + + var provider = await providerRepository.GetByIdAsync(providerId); + + if (provider == null) + { + context.Result = new NotFoundObjectResult(new ErrorResponseModel("Provider not found.")); + return; + } + + var currentContext = context.HttpContext.RequestServices.GetRequiredService(); + + var unauthorized = providerUserType switch + { + ProviderUserType.ProviderAdmin => !currentContext.ProviderProviderAdmin(providerId), + ProviderUserType.ServiceUser => !currentContext.ProviderUser(providerId), + _ => false + }; + + if (unauthorized) + { + context.Result = new UnauthorizedObjectResult(new ErrorResponseModel("Unauthorized.")); + return; + } + + var providerParameter = context.ActionDescriptor.Parameters + .FirstOrDefault(p => p.ParameterType == typeof(Provider)); + + if (providerParameter != null) + { + context.ActionArguments[providerParameter.Name] = provider; + } + + await next(); + } +} diff --git a/src/Api/Billing/Attributes/InjectUserAttribute.cs b/src/Api/Billing/Attributes/InjectUserAttribute.cs new file mode 100644 index 0000000000..0b614bdc44 --- /dev/null +++ b/src/Api/Billing/Attributes/InjectUserAttribute.cs @@ -0,0 +1,53 @@ +#nullable enable +using Bit.Core.Entities; +using Bit.Core.Models.Api; +using Bit.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Bit.Api.Billing.Attributes; + +/// +/// An action filter that facilitates the injection of a parameter into the executing action method arguments. +/// +/// +/// This attribute retrieves the authorized user associated with the current HTTP context using the service. +/// If the user is unauthorized or cannot be found, the request is terminated with an unauthorized response. +/// The injected +/// parameter must be marked with a [BindNever] attribute to short-circuit the model-binding system. +/// +/// +/// EndpointAsync([BindNever] User user) +/// ]]> +/// +/// +public class InjectUserAttribute : ActionFilterAttribute +{ + public override async Task OnActionExecutionAsync( + ActionExecutingContext context, + ActionExecutionDelegate next) + { + var userService = context.HttpContext.RequestServices.GetRequiredService(); + + var user = await userService.GetUserByPrincipalAsync(context.HttpContext.User); + + if (user == null) + { + context.Result = new UnauthorizedObjectResult(new ErrorResponseModel("Unauthorized.")); + return; + } + + var userParameter = + context.ActionDescriptor.Parameters.FirstOrDefault(parameter => parameter.ParameterType == typeof(User)); + + if (userParameter != null) + { + context.ActionArguments[userParameter.Name] = user; + } + + await next(); + } +} diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 49ff679bb8..9411d454aa 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -5,15 +5,12 @@ using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Services; -using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -161,8 +158,6 @@ public class AccountsController( [HttpPost("cancel")] public async Task PostCancelAsync( [FromBody] SubscriptionCancellationRequestModel request, - [FromServices] ICurrentContext currentContext, - [FromServices] IReferenceEventService referenceEventService, [FromServices] ISubscriberService subscriberService) { var user = await userService.GetUserByPrincipalAsync(User); @@ -175,12 +170,6 @@ public class AccountsController( await subscriberService.CancelSubscription(user, new OffboardingSurveyResponse { UserId = user.Id, Reason = request.Reason, Feedback = request.Feedback }, user.IsExpired()); - - await referenceEventService.RaiseEventAsync(new ReferenceEvent( - ReferenceEventType.CancelSubscription, - user, - currentContext) - { EndOfPeriod = user.IsExpired() }); } [HttpPost("reinstate-premium")] diff --git a/src/Api/Billing/Controllers/BaseBillingController.cs b/src/Api/Billing/Controllers/BaseBillingController.cs index 5f7005fdfc..057c8309fb 100644 --- a/src/Api/Billing/Controllers/BaseBillingController.cs +++ b/src/Api/Billing/Controllers/BaseBillingController.cs @@ -1,4 +1,6 @@ -using Bit.Core.Models.Api; +#nullable enable +using Bit.Core.Billing.Commands; +using Bit.Core.Models.Api; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -6,20 +8,50 @@ namespace Bit.Api.Billing.Controllers; public abstract class BaseBillingController : Controller { + /// + /// Processes the result of a billing command and converts it to an appropriate HTTP result response. + /// + /// + /// Result to response mappings: + /// + /// : 200 OK + /// : 400 BAD_REQUEST + /// : 409 CONFLICT + /// : 500 INTERNAL_SERVER_ERROR + /// + /// + /// The type of the successful result. + /// The result of executing the billing command. + /// An HTTP result response representing the outcome of the command execution. + protected static IResult Handle(BillingCommandResult result) => + result.Match( + TypedResults.Ok, + badRequest => Error.BadRequest(badRequest.Response), + conflict => Error.Conflict(conflict.Response), + unhandled => Error.ServerError(unhandled.Response, unhandled.Exception)); + protected static class Error { - public static BadRequest BadRequest(Dictionary> errors) => - TypedResults.BadRequest(new ErrorResponseModel(errors)); - public static BadRequest BadRequest(string message) => TypedResults.BadRequest(new ErrorResponseModel(message)); + public static JsonHttpResult Conflict(string message) => + TypedResults.Json( + new ErrorResponseModel(message), + statusCode: StatusCodes.Status409Conflict); + public static NotFound NotFound() => TypedResults.NotFound(new ErrorResponseModel("Resource not found.")); - public static JsonHttpResult ServerError(string message = "Something went wrong with your request. Please contact support.") => + public static JsonHttpResult ServerError( + string message = "Something went wrong with your request. Please contact support for assistance.", + Exception? exception = null) => TypedResults.Json( - new ErrorResponseModel(message), + exception == null ? new ErrorResponseModel(message) : new ErrorResponseModel(message) + { + ExceptionMessage = exception.Message, + ExceptionStackTrace = exception.StackTrace + }, statusCode: StatusCodes.Status500InternalServerError); public static JsonHttpResult Unauthorized(string message = "Unauthorized.") => diff --git a/src/Api/Billing/Controllers/BaseProviderController.cs b/src/Api/Billing/Controllers/BaseProviderController.cs index 038abfaa9e..782bffbc70 100644 --- a/src/Api/Billing/Controllers/BaseProviderController.cs +++ b/src/Api/Billing/Controllers/BaseProviderController.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Extensions; using Bit.Core.Context; diff --git a/src/Api/Billing/Controllers/InvoicesController.cs b/src/Api/Billing/Controllers/InvoicesController.cs index 5a1d732f42..30ea975e09 100644 --- a/src/Api/Billing/Controllers/InvoicesController.cs +++ b/src/Api/Billing/Controllers/InvoicesController.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Tax.Requests; using Bit.Core.Context; using Bit.Core.Repositories; diff --git a/src/Api/Controllers/LicensesController.cs b/src/Api/Billing/Controllers/LicensesController.cs similarity index 82% rename from src/Api/Controllers/LicensesController.cs rename to src/Api/Billing/Controllers/LicensesController.cs index 1c00589201..29313bd4d8 100644 --- a/src/Api/Controllers/LicensesController.cs +++ b/src/Api/Billing/Controllers/LicensesController.cs @@ -1,16 +1,20 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Models.Api.OrganizationLicenses; -using Bit.Core.Models.Business; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Controllers; +namespace Bit.Api.Billing.Controllers; [Route("licenses")] [Authorize("Licensing")] @@ -20,7 +24,7 @@ public class LicensesController : Controller private readonly IUserRepository _userRepository; private readonly IUserService _userService; private readonly IOrganizationRepository _organizationRepository; - private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery; + private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery; private readonly IValidateBillingSyncKeyCommand _validateBillingSyncKeyCommand; private readonly ICurrentContext _currentContext; @@ -28,14 +32,14 @@ public class LicensesController : Controller IUserRepository userRepository, IUserService userService, IOrganizationRepository organizationRepository, - ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, + IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery, IValidateBillingSyncKeyCommand validateBillingSyncKeyCommand, ICurrentContext currentContext) { _userRepository = userRepository; _userService = userService; _organizationRepository = organizationRepository; - _cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery; + _getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery; _validateBillingSyncKeyCommand = validateBillingSyncKeyCommand; _currentContext = currentContext; } @@ -81,7 +85,7 @@ public class LicensesController : Controller throw new BadRequestException("Invalid Billing Sync Key"); } - var license = await _cloudGetOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value); + var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value); return license; } } diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 094ca0a435..4915e5ef8e 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -3,10 +3,11 @@ using System.Diagnostics; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; -using Bit.Api.Billing.Queries.Organizations; -using Bit.Core; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; @@ -25,10 +26,9 @@ namespace Bit.Api.Billing.Controllers; public class OrganizationBillingController( IBusinessUnitConverter businessUnitConverter, ICurrentContext currentContext, - IFeatureService featureService, IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, - IOrganizationWarningsQuery organizationWarningsQuery, + IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IPaymentService paymentService, IPricingClient pricingClient, ISubscriberService subscriberService, @@ -282,17 +282,36 @@ public class OrganizationBillingController( } var organization = await organizationRepository.GetByIdAsync(organizationId); - if (organization == null) { return Error.NotFound(); } + var existingPlan = organization.PlanType; var organizationSignup = model.ToOrganizationSignup(user); var sale = OrganizationSale.From(organization, organizationSignup); var plan = await pricingClient.GetPlanOrThrow(model.PlanType); sale.Organization.PlanType = plan.Type; sale.Organization.Plan = plan.Name; sale.SubscriptionSetup.SkipTrial = true; + if (existingPlan == PlanType.Free && organization.GatewaySubscriptionId is not null) + { + sale.Organization.UseTotp = plan.HasTotp; + sale.Organization.UseGroups = plan.HasGroups; + sale.Organization.UseDirectory = plan.HasDirectory; + sale.Organization.SelfHost = plan.HasSelfHost; + sale.Organization.UsersGetPremium = plan.UsersGetPremium; + sale.Organization.UseEvents = plan.HasEvents; + sale.Organization.Use2fa = plan.Has2fa; + sale.Organization.UseApi = plan.HasApi; + sale.Organization.UsePolicies = plan.HasPolicies; + sale.Organization.UseSso = plan.HasSso; + sale.Organization.UseResetPassword = plan.HasResetPassword; + sale.Organization.UseKeyConnector = plan.HasKeyConnector; + sale.Organization.UseScim = plan.HasScim; + sale.Organization.UseCustomPermissions = plan.HasCustomPermissions; + sale.Organization.UseOrganizationDomains = plan.HasOrganizationDomains; + sale.Organization.MaxCollections = plan.PasswordManager.MaxCollections; + } if (organizationSignup.PaymentMethodType == null || string.IsNullOrEmpty(organizationSignup.PaymentToken)) { @@ -318,14 +337,6 @@ public class OrganizationBillingController( [FromRoute] Guid organizationId, [FromBody] SetupBusinessUnitRequestBody requestBody) { - var enableOrganizationBusinessUnitConversion = - featureService.IsEnabled(FeatureFlagKeys.PM18770_EnableOrganizationBusinessUnitConversion); - - if (!enableOrganizationBusinessUnitConversion) - { - return Error.NotFound(); - } - var organization = await organizationRepository.GetByIdAsync(organizationId); if (organization == null) @@ -352,7 +363,7 @@ public class OrganizationBillingController( public async Task GetWarningsAsync([FromRoute] Guid organizationId) { /* - * We'll keep these available at the User level, because we're hiding any pertinent information and + * We'll keep these available at the User level because we're hiding any pertinent information, and * we want to throw as few errors as possible since these are not core features. */ if (!await currentContext.OrganizationUser(organizationId)) @@ -367,8 +378,39 @@ public class OrganizationBillingController( return Error.NotFound(); } - var response = await organizationWarningsQuery.Run(organization); + var warnings = await getOrganizationWarningsQuery.Run(organization); - return TypedResults.Ok(response); + return TypedResults.Ok(warnings); + } + + + [HttpPost("change-frequency")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task ChangePlanSubscriptionFrequencyAsync( + [FromRoute] Guid organizationId, + [FromBody] ChangePlanFrequencyRequest request) + { + if (!await currentContext.EditSubscription(organizationId)) + { + return Error.Unauthorized(); + } + + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + return Error.NotFound(); + } + + if (organization.PlanType == request.NewPlanType) + { + return Error.BadRequest("Organization is already on the requested plan frequency."); + } + + await organizationBillingService.UpdateSubscriptionPlanFrequency( + organization, + request.NewPlanType); + + return TypedResults.Ok(); } } diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index c45b34422c..2d05595b2d 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Request.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Api.Models.Response.Organizations; using Bit.Core.AdminConsole.Enums; diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index bd5ab8cef4..977b20bdfb 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Request.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Request; @@ -6,23 +9,21 @@ using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Entities; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Organizations.Entities; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Billing.Organizations.Repositories; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -38,13 +39,12 @@ public class OrganizationsController( IUserService userService, IPaymentService paymentService, ICurrentContext currentContext, - ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery, + IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery, GlobalSettings globalSettings, ILicensingService licensingService, IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand, IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand, - IReferenceEventService referenceEventService, ISubscriberService subscriberService, IOrganizationInstallationRepository organizationInstallationRepository, IPricingClient pricingClient) @@ -98,7 +98,7 @@ public class OrganizationsController( } var org = await organizationRepository.GetByIdAsync(id); - var license = await cloudGetOrganizationLicenseQuery.GetLicenseAsync(org, installationId); + var license = await getCloudOrganizationLicenseQuery.GetLicenseAsync(org, installationId); if (license == null) { throw new NotFoundException(); @@ -246,14 +246,6 @@ public class OrganizationsController( Feedback = request.Feedback }, organization.IsExpired()); - - await referenceEventService.RaiseEventAsync(new ReferenceEvent( - ReferenceEventType.CancelSubscription, - organization, - currentContext) - { - EndOfPeriod = organization.IsExpired() - }); } [HttpPost("{id:guid}/reinstate")] diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 37130d54ce..80b145a2e0 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Billing.Models.Requests; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core; @@ -81,13 +84,6 @@ public class ProviderBillingController( [FromRoute] Guid providerId, [FromBody] UpdatePaymentMethodRequestBody requestBody) { - var allowProviderPaymentMethod = featureService.IsEnabled(FeatureFlagKeys.PM18794_ProviderPaymentMethod); - - if (!allowProviderPaymentMethod) - { - return TypedResults.NotFound(); - } - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); if (provider == null) @@ -111,13 +107,6 @@ public class ProviderBillingController( [FromRoute] Guid providerId, [FromBody] VerifyBankAccountRequestBody requestBody) { - var allowProviderPaymentMethod = featureService.IsEnabled(FeatureFlagKeys.PM18794_ProviderPaymentMethod); - - if (!allowProviderPaymentMethod) - { - return TypedResults.NotFound(); - } - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); if (provider == null) diff --git a/src/Api/Billing/Controllers/TaxController.cs b/src/Api/Billing/Controllers/TaxController.cs index 7b8b9d960f..d2c1c36726 100644 --- a/src/Api/Billing/Controllers/TaxController.cs +++ b/src/Api/Billing/Controllers/TaxController.cs @@ -28,9 +28,6 @@ public class TaxController( var result = await previewTaxAmountCommand.Run(parameters); - return result.Match( - taxAmount => TypedResults.Ok(new { TaxAmount = taxAmount }), - badRequest => Error.BadRequest(badRequest.TranslationKey), - unhandled => Error.ServerError(unhandled.TranslationKey)); + return Handle(result); } } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs new file mode 100644 index 0000000000..e3b702e36d --- /dev/null +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -0,0 +1,64 @@ +#nullable enable +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Payment.Commands; +using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Entities; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Bit.Api.Billing.Controllers.VNext; + +[Authorize("Application")] +[Route("account/billing/vnext")] +[SelfHosted(NotSelfHostedOnly = true)] +public class AccountBillingVNextController( + ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, + IGetCreditQuery getCreditQuery, + IGetPaymentMethodQuery getPaymentMethodQuery, + IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController +{ + [HttpGet("credit")] + [InjectUser] + public async Task GetCreditAsync( + [BindNever] User user) + { + var credit = await getCreditQuery.Run(user); + return TypedResults.Ok(credit); + } + + [HttpPost("credit/bitpay")] + [InjectUser] + public async Task AddCreditViaBitPayAsync( + [BindNever] User user, + [FromBody] BitPayCreditRequest request) + { + var result = await createBitPayInvoiceForCreditCommand.Run( + user, + request.Amount, + request.RedirectUrl); + return Handle(result); + } + + [HttpGet("payment-method")] + [InjectUser] + public async Task GetPaymentMethodAsync( + [BindNever] User user) + { + var paymentMethod = await getPaymentMethodQuery.Run(user); + return TypedResults.Ok(paymentMethod); + } + + [HttpPut("payment-method")] + [InjectUser] + public async Task UpdatePaymentMethodAsync( + [BindNever] User user, + [FromBody] TokenizedPaymentMethodRequest request) + { + var (paymentMethod, billingAddress) = request.ToDomain(); + var result = await updatePaymentMethodCommand.Run(user, paymentMethod, billingAddress); + return Handle(result); + } +} diff --git a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs new file mode 100644 index 0000000000..429f2065f6 --- /dev/null +++ b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs @@ -0,0 +1,107 @@ +#nullable enable +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Api.Billing.Models.Requirements; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Payment.Commands; +using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +// ReSharper disable RouteTemplates.MethodMissingRouteParameters + +namespace Bit.Api.Billing.Controllers.VNext; + +[Authorize("Application")] +[Route("organizations/{organizationId:guid}/billing/vnext")] +[SelfHosted(NotSelfHostedOnly = true)] +public class OrganizationBillingVNextController( + ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, + IGetBillingAddressQuery getBillingAddressQuery, + IGetCreditQuery getCreditQuery, + IGetPaymentMethodQuery getPaymentMethodQuery, + IUpdateBillingAddressCommand updateBillingAddressCommand, + IUpdatePaymentMethodCommand updatePaymentMethodCommand, + IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController +{ + [Authorize] + [HttpGet("address")] + [InjectOrganization] + public async Task GetBillingAddressAsync( + [BindNever] Organization organization) + { + var billingAddress = await getBillingAddressQuery.Run(organization); + return TypedResults.Ok(billingAddress); + } + + [Authorize] + [HttpPut("address")] + [InjectOrganization] + public async Task UpdateBillingAddressAsync( + [BindNever] Organization organization, + [FromBody] BillingAddressRequest request) + { + var billingAddress = request.ToDomain(); + var result = await updateBillingAddressCommand.Run(organization, billingAddress); + return Handle(result); + } + + [Authorize] + [HttpGet("credit")] + [InjectOrganization] + public async Task GetCreditAsync( + [BindNever] Organization organization) + { + var credit = await getCreditQuery.Run(organization); + return TypedResults.Ok(credit); + } + + [Authorize] + [HttpPost("credit/bitpay")] + [InjectOrganization] + public async Task AddCreditViaBitPayAsync( + [BindNever] Organization organization, + [FromBody] BitPayCreditRequest request) + { + var result = await createBitPayInvoiceForCreditCommand.Run( + organization, + request.Amount, + request.RedirectUrl); + return Handle(result); + } + + [Authorize] + [HttpGet("payment-method")] + [InjectOrganization] + public async Task GetPaymentMethodAsync( + [BindNever] Organization organization) + { + var paymentMethod = await getPaymentMethodQuery.Run(organization); + return TypedResults.Ok(paymentMethod); + } + + [Authorize] + [HttpPut("payment-method")] + [InjectOrganization] + public async Task UpdatePaymentMethodAsync( + [BindNever] Organization organization, + [FromBody] TokenizedPaymentMethodRequest request) + { + var (paymentMethod, billingAddress) = request.ToDomain(); + var result = await updatePaymentMethodCommand.Run(organization, paymentMethod, billingAddress); + return Handle(result); + } + + [Authorize] + [HttpPost("payment-method/verify-bank-account")] + [InjectOrganization] + public async Task VerifyBankAccountAsync( + [BindNever] Organization organization, + [FromBody] VerifyBankAccountRequest request) + { + var result = await verifyBankAccountCommand.Run(organization, request.DescriptorCode); + return Handle(result); + } +} diff --git a/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs new file mode 100644 index 0000000000..d0cc377245 --- /dev/null +++ b/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs @@ -0,0 +1,107 @@ +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Payment.Commands; +using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +// ReSharper disable RouteTemplates.MethodMissingRouteParameters + +namespace Bit.Api.Billing.Controllers.VNext; + +[Route("providers/{providerId:guid}/billing/vnext")] +[SelfHosted(NotSelfHostedOnly = true)] +public class ProviderBillingVNextController( + ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, + IGetBillingAddressQuery getBillingAddressQuery, + IGetCreditQuery getCreditQuery, + IGetPaymentMethodQuery getPaymentMethodQuery, + IProviderService providerService, + IUpdateBillingAddressCommand updateBillingAddressCommand, + IUpdatePaymentMethodCommand updatePaymentMethodCommand, + IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController +{ + [HttpGet("address")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task GetBillingAddressAsync( + [BindNever] Provider provider) + { + var billingAddress = await getBillingAddressQuery.Run(provider); + return TypedResults.Ok(billingAddress); + } + + [HttpPut("address")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task UpdateBillingAddressAsync( + [BindNever] Provider provider, + [FromBody] BillingAddressRequest request) + { + var billingAddress = request.ToDomain(); + var result = await updateBillingAddressCommand.Run(provider, billingAddress); + return Handle(result); + } + + [HttpGet("credit")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task GetCreditAsync( + [BindNever] Provider provider) + { + var credit = await getCreditQuery.Run(provider); + return TypedResults.Ok(credit); + } + + [HttpPost("credit/bitpay")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task AddCreditViaBitPayAsync( + [BindNever] Provider provider, + [FromBody] BitPayCreditRequest request) + { + var result = await createBitPayInvoiceForCreditCommand.Run( + provider, + request.Amount, + request.RedirectUrl); + return Handle(result); + } + + [HttpGet("payment-method")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task GetPaymentMethodAsync( + [BindNever] Provider provider) + { + var paymentMethod = await getPaymentMethodQuery.Run(provider); + return TypedResults.Ok(paymentMethod); + } + + [HttpPut("payment-method")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task UpdatePaymentMethodAsync( + [BindNever] Provider provider, + [FromBody] TokenizedPaymentMethodRequest request) + { + var (paymentMethod, billingAddress) = request.ToDomain(); + var result = await updatePaymentMethodCommand.Run(provider, paymentMethod, billingAddress); + // TODO: Temporary until we can send Provider notifications from the Billing API + if (!provider.Enabled) + { + await result.TapAsync(async _ => + { + provider.Enabled = true; + await providerService.UpdateAsync(provider); + }); + } + return Handle(result); + } + + [HttpPost("payment-method/verify-bank-account")] + [InjectProvider(ProviderUserType.ProviderAdmin)] + public async Task VerifyBankAccountAsync( + [BindNever] Provider provider, + [FromBody] VerifyBankAccountRequest request) + { + var result = await verifyBankAccountCommand.Run(provider, request.DescriptorCode); + return Handle(result); + } +} diff --git a/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs b/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs index c2add17793..f23ce266c8 100644 --- a/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Requests/ChangePlanFrequencyRequest.cs b/src/Api/Billing/Models/Requests/ChangePlanFrequencyRequest.cs new file mode 100644 index 0000000000..88fff85cb3 --- /dev/null +++ b/src/Api/Billing/Models/Requests/ChangePlanFrequencyRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Enums; + +namespace Bit.Api.Billing.Models.Requests; + +public class ChangePlanFrequencyRequest +{ + [Required] + public PlanType NewPlanType { get; set; } +} diff --git a/src/Api/Billing/Models/Requests/CreateClientOrganizationRequestBody.cs b/src/Api/Billing/Models/Requests/CreateClientOrganizationRequestBody.cs index 95836151d6..243126f7ac 100644 --- a/src/Api/Billing/Models/Requests/CreateClientOrganizationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/CreateClientOrganizationRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Utilities; using Bit.Core.Billing.Enums; diff --git a/src/Api/Billing/Models/Requests/KeyPairRequestBody.cs b/src/Api/Billing/Models/Requests/KeyPairRequestBody.cs index b4f2c00f4f..2fec3bd61d 100644 --- a/src/Api/Billing/Models/Requests/KeyPairRequestBody.cs +++ b/src/Api/Billing/Models/Requests/KeyPairRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs new file mode 100644 index 0000000000..5c3c47f585 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs @@ -0,0 +1,20 @@ +#nullable enable +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public record BillingAddressRequest : CheckoutBillingAddressRequest +{ + public string? Line1 { get; set; } + public string? Line2 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + + public override BillingAddress ToDomain() => base.ToDomain() with + { + Line1 = Line1, + Line2 = Line2, + City = City, + State = State, + }; +} diff --git a/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs b/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs new file mode 100644 index 0000000000..bb6e7498d7 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs @@ -0,0 +1,13 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public record BitPayCreditRequest +{ + [Required] + public required decimal Amount { get; set; } + + [Required] + public required string RedirectUrl { get; set; } = null!; +} diff --git a/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs new file mode 100644 index 0000000000..54116e897d --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs @@ -0,0 +1,24 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public record CheckoutBillingAddressRequest : MinimalBillingAddressRequest +{ + public TaxIdRequest? TaxId { get; set; } + + public override BillingAddress ToDomain() => base.ToDomain() with + { + TaxId = TaxId != null ? new TaxID(TaxId.Code, TaxId.Value) : null + }; + + public class TaxIdRequest + { + [Required] + public string Code { get; set; } = null!; + + [Required] + public string Value { get; set; } = null!; + } +} diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs new file mode 100644 index 0000000000..b4d28017d5 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs @@ -0,0 +1,16 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public record MinimalBillingAddressRequest +{ + [Required] + [StringLength(2, MinimumLength = 2, ErrorMessage = "Country code must be 2 characters long.")] + public required string Country { get; set; } = null!; + [Required] + public required string PostalCode { get; set; } = null!; + + public virtual BillingAddress ToDomain() => new() { Country = Country, PostalCode = PostalCode, }; +} diff --git a/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs new file mode 100644 index 0000000000..663e4e7cd2 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs @@ -0,0 +1,39 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Api.Utilities; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public class TokenizedPaymentMethodRequest +{ + [Required] + [StringMatches("bankAccount", "card", "payPal", + ErrorMessage = "Payment method type must be one of: bankAccount, card, payPal")] + public required string Type { get; set; } + + [Required] + public required string Token { get; set; } + + public MinimalBillingAddressRequest? BillingAddress { get; set; } + + public (TokenizedPaymentMethod, BillingAddress?) ToDomain() + { + var paymentMethod = new TokenizedPaymentMethod + { + Type = Type switch + { + "bankAccount" => TokenizablePaymentMethodType.BankAccount, + "card" => TokenizablePaymentMethodType.Card, + "payPal" => TokenizablePaymentMethodType.PayPal, + _ => throw new InvalidOperationException( + $"Invalid value for {nameof(TokenizedPaymentMethod)}.{nameof(TokenizedPaymentMethod.Type)}") + }, + Token = Token + }; + + var billingAddress = BillingAddress?.ToDomain(); + + return (paymentMethod, billingAddress); + } +} diff --git a/src/Api/Billing/Models/Requests/Payment/VerifyBankAccountRequest.cs b/src/Api/Billing/Models/Requests/Payment/VerifyBankAccountRequest.cs new file mode 100644 index 0000000000..2b5d6a0cb1 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/VerifyBankAccountRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public class VerifyBankAccountRequest +{ + [Required] + public required string DescriptorCode { get; set; } +} diff --git a/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs b/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs index c4b87a01f5..bbc6a9acda 100644 --- a/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs +++ b/src/Api/Billing/Models/Requests/SetupBusinessUnitRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs index edc45ce483..a1b754a9dc 100644 --- a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs b/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs index 4f087913b9..b469ce2576 100644 --- a/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs +++ b/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Utilities; using Bit.Core.Billing.Models; using Bit.Core.Enums; diff --git a/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs b/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs index 6ed1083b42..7c393e342a 100644 --- a/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/UpdateClientOrganizationRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs b/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs index cdc9a08851..05ab1e34c9 100644 --- a/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs +++ b/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs b/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs index 3e97d07a90..e248d55dde 100644 --- a/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs +++ b/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests; diff --git a/src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs b/src/Api/Billing/Models/Requirements/ManageOrganizationBillingRequirement.cs similarity index 61% rename from src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs rename to src/Api/Billing/Models/Requirements/ManageOrganizationBillingRequirement.cs index 84f38e36c2..4efdf0812a 100644 --- a/src/Api/AdminConsole/Authorization/Requirements/ManageUsersRequirement.cs +++ b/src/Api/Billing/Models/Requirements/ManageOrganizationBillingRequirement.cs @@ -1,11 +1,11 @@ #nullable enable - +using Bit.Api.AdminConsole.Authorization; using Bit.Core.Context; using Bit.Core.Enums; -namespace Bit.Api.AdminConsole.Authorization.Requirements; +namespace Bit.Api.Billing.Models.Requirements; -public class ManageUsersRequirement : IOrganizationRequirement +public class ManageOrganizationBillingRequirement : IOrganizationRequirement { public async Task AuthorizeAsync( CurrentContextOrganization? organizationClaims, @@ -13,8 +13,6 @@ public class ManageUsersRequirement : IOrganizationRequirement => organizationClaims switch { { Type: OrganizationUserType.Owner } => true, - { Type: OrganizationUserType.Admin } => true, - { Permissions.ManageUsers: true } => true, _ => await isProviderUserForOrg() }; } diff --git a/src/Api/Billing/Models/Responses/BillingHistoryResponseModel.cs b/src/Api/Billing/Models/Responses/BillingHistoryResponseModel.cs index 0a4ebdb8dd..9f68fe41a4 100644 --- a/src/Api/Billing/Models/Responses/BillingHistoryResponseModel.cs +++ b/src/Api/Billing/Models/Responses/BillingHistoryResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models; using Bit.Core.Enums; using Bit.Core.Models.Api; diff --git a/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs b/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs index 5c43522aca..f305e41c4f 100644 --- a/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs +++ b/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models; using Bit.Core.Models.Api; namespace Bit.Api.Billing.Models.Responses; diff --git a/src/Api/Billing/Models/Responses/BillingResponseModel.cs b/src/Api/Billing/Models/Responses/BillingResponseModel.cs index 172f784b50..67f4c98f9d 100644 --- a/src/Api/Billing/Models/Responses/BillingResponseModel.cs +++ b/src/Api/Billing/Models/Responses/BillingResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models; using Bit.Core.Enums; using Bit.Core.Models.Api; diff --git a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs index 1dfc79be21..a13f267c3b 100644 --- a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs +++ b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs @@ -1,4 +1,4 @@ -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Organizations.Models; namespace Bit.Api.Billing.Models.Responses; @@ -12,7 +12,8 @@ public record OrganizationMetadataResponse( bool IsSubscriptionCanceled, DateTime? InvoiceDueDate, DateTime? InvoiceCreatedDate, - DateTime? SubPeriodEndDate) + DateTime? SubPeriodEndDate, + int OrganizationOccupiedSeats) { public static OrganizationMetadataResponse From(OrganizationMetadata metadata) => new( @@ -25,5 +26,6 @@ public record OrganizationMetadataResponse( metadata.IsSubscriptionCanceled, metadata.InvoiceDueDate, metadata.InvoiceCreatedDate, - metadata.SubPeriodEndDate); + metadata.SubPeriodEndDate, + metadata.OrganizationOccupiedSeats); } diff --git a/src/Api/Billing/Public/Controllers/OrganizationController.cs b/src/Api/Billing/Public/Controllers/OrganizationController.cs index b0a0537ed8..79033ba31e 100644 --- a/src/Api/Billing/Public/Controllers/OrganizationController.cs +++ b/src/Api/Billing/Public/Controllers/OrganizationController.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.Billing.Public.Models; using Bit.Api.Models.Public.Response; using Bit.Core.Billing.Pricing; diff --git a/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs b/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs index 5c75db5924..4ccbdb04e8 100644 --- a/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs +++ b/src/Api/Billing/Public/Models/Request/OrganizationSubscriptionUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; diff --git a/src/Api/Billing/Public/Models/Response/OrganizationSubscriptionDetailsResponseModel.cs b/src/Api/Billing/Public/Models/Response/OrganizationSubscriptionDetailsResponseModel.cs index 09aa7decc1..0a3b7b1421 100644 --- a/src/Api/Billing/Public/Models/Response/OrganizationSubscriptionDetailsResponseModel.cs +++ b/src/Api/Billing/Public/Models/Response/OrganizationSubscriptionDetailsResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Public.Models; diff --git a/src/Api/Billing/Registrations.cs b/src/Api/Billing/Registrations.cs deleted file mode 100644 index cb92098333..0000000000 --- a/src/Api/Billing/Registrations.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Bit.Api.Billing.Queries.Organizations; - -namespace Bit.Api.Billing; - -public static class Registrations -{ - public static void AddBillingQueries(this IServiceCollection services) - { - services.AddTransient(); - } -} diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index e0f1c0d2c8..6708a66326 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Request; using Bit.Api.Models.Response; using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core.Context; @@ -19,7 +22,8 @@ namespace Bit.Api.Controllers; public class CollectionsController : Controller { private readonly ICollectionRepository _collectionRepository; - private readonly ICollectionService _collectionService; + private readonly ICreateCollectionCommand _createCollectionCommand; + private readonly IUpdateCollectionCommand _updateCollectionCommand; private readonly IDeleteCollectionCommand _deleteCollectionCommand; private readonly IUserService _userService; private readonly IAuthorizationService _authorizationService; @@ -28,7 +32,8 @@ public class CollectionsController : Controller public CollectionsController( ICollectionRepository collectionRepository, - ICollectionService collectionService, + ICreateCollectionCommand createCollectionCommand, + IUpdateCollectionCommand updateCollectionCommand, IDeleteCollectionCommand deleteCollectionCommand, IUserService userService, IAuthorizationService authorizationService, @@ -36,7 +41,8 @@ public class CollectionsController : Controller IBulkAddCollectionAccessCommand bulkAddCollectionAccessCommand) { _collectionRepository = collectionRepository; - _collectionService = collectionService; + _createCollectionCommand = createCollectionCommand; + _updateCollectionCommand = updateCollectionCommand; _deleteCollectionCommand = deleteCollectionCommand; _userService = userService; _authorizationService = authorizationService; @@ -103,7 +109,7 @@ public class CollectionsController : Controller var readAllAuthorized = (await _authorizationService.AuthorizeAsync(User, CollectionOperations.ReadAll(orgId))).Succeeded; if (readAllAuthorized) { - orgCollections = await _collectionRepository.GetManyByOrganizationIdAsync(orgId); + orgCollections = await _collectionRepository.GetManySharedCollectionsByOrganizationIdAsync(orgId); } else { @@ -153,7 +159,7 @@ public class CollectionsController : Controller var groups = model.Groups?.Select(g => g.ToSelectionReadOnly()); var users = model.Users?.Select(g => g.ToSelectionReadOnly()).ToList() ?? new List(); - await _collectionService.SaveAsync(collection, groups, users); + await _createCollectionCommand.CreateAsync(collection, groups, users); if (!_currentContext.UserId.HasValue || (_currentContext.GetOrganization(orgId) == null && await _currentContext.ProviderUserForOrgAsync(orgId))) { @@ -179,7 +185,7 @@ public class CollectionsController : Controller var groups = model.Groups?.Select(g => g.ToSelectionReadOnly()); var users = model.Users?.Select(g => g.ToSelectionReadOnly()); - await _collectionService.SaveAsync(model.ToCollection(collection), groups, users); + await _updateCollectionCommand.UpdateAsync(model.ToCollection(collection), groups, users); if (!_currentContext.UserId.HasValue || (_currentContext.GetOrganization(collection.OrganizationId) == null && await _currentContext.ProviderUserForOrgAsync(collection.OrganizationId))) { @@ -192,19 +198,6 @@ public class CollectionsController : Controller return new CollectionAccessDetailsResponseModel(collectionWithPermissions); } - [HttpPut("{id}/users")] - public async Task PutUsers(Guid orgId, Guid id, [FromBody] IEnumerable model) - { - var collection = await _collectionRepository.GetByIdAsync(id); - var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.ModifyUserAccess)).Succeeded; - if (!authorized) - { - throw new NotFoundException(); - } - - await _collectionRepository.UpdateUsersAsync(collection.Id, model?.Select(g => g.ToSelectionReadOnly())); - } - [HttpPost("bulk-access")] public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model) { @@ -255,18 +248,4 @@ public class CollectionsController : Controller await _deleteCollectionCommand.DeleteManyAsync(collections); } - - [HttpDelete("{id}/user/{orgUserId}")] - [HttpPost("{id}/delete-user/{orgUserId}")] - public async Task DeleteUser(Guid orgId, Guid id, Guid orgUserId) - { - var collection = await _collectionRepository.GetByIdAsync(id); - var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.ModifyUserAccess)).Succeeded; - if (!authorized) - { - throw new NotFoundException(); - } - - await _collectionService.DeleteUserAsync(collection, orgUserId); - } } diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index 0ff4e93abe..07e8552268 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.Auth.Models.Request; using Bit.Api.Models.Request; using Bit.Api.Models.Response; @@ -206,7 +209,11 @@ public class DevicesController : Controller throw new NotFoundException(); } - await _deviceService.SaveAsync(model.ToData(), device); + await _deviceService.SaveAsync( + model.ToData(), + device, + _currentContext.Organizations.Select(org => org.Id.ToString()) + ); } [AllowAnonymous] diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs index ed501c41da..b4eecdba0f 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs @@ -1,13 +1,18 @@ -using Bit.Api.AdminConsole.Models.Response.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Request; using Bit.Api.Models.Request.Organizations; using Bit.Api.Utilities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Models.OrganizationConnectionConfigs; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; @@ -22,26 +27,26 @@ namespace Bit.Api.Controllers.SelfHosted; public class SelfHostedOrganizationLicensesController : Controller { private readonly ICurrentContext _currentContext; - private readonly ISelfHostedGetOrganizationLicenseQuery _selfHostedGetOrganizationLicenseQuery; + private readonly IGetSelfHostedOrganizationLicenseQuery _getSelfHostedOrganizationLicenseQuery; private readonly IOrganizationConnectionRepository _organizationConnectionRepository; - private readonly IOrganizationService _organizationService; + private readonly ISelfHostedOrganizationSignUpCommand _selfHostedOrganizationSignUpCommand; private readonly IOrganizationRepository _organizationRepository; private readonly IUserService _userService; private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand; public SelfHostedOrganizationLicensesController( ICurrentContext currentContext, - ISelfHostedGetOrganizationLicenseQuery selfHostedGetOrganizationLicenseQuery, + IGetSelfHostedOrganizationLicenseQuery getSelfHostedOrganizationLicenseQuery, IOrganizationConnectionRepository organizationConnectionRepository, - IOrganizationService organizationService, + ISelfHostedOrganizationSignUpCommand selfHostedOrganizationSignUpCommand, IOrganizationRepository organizationRepository, IUserService userService, IUpdateOrganizationLicenseCommand updateOrganizationLicenseCommand) { _currentContext = currentContext; - _selfHostedGetOrganizationLicenseQuery = selfHostedGetOrganizationLicenseQuery; + _getSelfHostedOrganizationLicenseQuery = getSelfHostedOrganizationLicenseQuery; _organizationConnectionRepository = organizationConnectionRepository; - _organizationService = organizationService; + _selfHostedOrganizationSignUpCommand = selfHostedOrganizationSignUpCommand; _organizationRepository = organizationRepository; _userService = userService; _updateOrganizationLicenseCommand = updateOrganizationLicenseCommand; @@ -62,7 +67,7 @@ public class SelfHostedOrganizationLicensesController : Controller throw new BadRequestException("Invalid license"); } - var result = await _organizationService.SignUpAsync(license, user, model.Key, + var result = await _selfHostedOrganizationSignUpCommand.SignUpAsync(license, user, model.Key, model.CollectionName, model.Keys?.PublicKey, model.Keys?.EncryptedPrivateKey); return new OrganizationResponseModel(result.Item1, null); @@ -117,7 +122,7 @@ public class SelfHostedOrganizationLicensesController : Controller } var license = - await _selfHostedGetOrganizationLicenseQuery.GetLicenseAsync(selfHostedOrganizationDetails, billingSyncConnection); + await _getSelfHostedOrganizationLicenseQuery.GetLicenseAsync(selfHostedOrganizationDetails, billingSyncConnection); var currentOrganization = await _organizationRepository.GetByLicenseKeyAsync(license.LicenseKey); await _updateOrganizationLicenseCommand.UpdateLicenseAsync(selfHostedOrganizationDetails, license, currentOrganization); diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs index 371b321a4c..de41a4cf10 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Authorization.Requirements; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Core.Context; diff --git a/src/Api/Dirt/Controllers/HibpController.cs b/src/Api/Dirt/Controllers/HibpController.cs index f12027cb31..d108fdbd4f 100644 --- a/src/Api/Dirt/Controllers/HibpController.cs +++ b/src/Api/Dirt/Controllers/HibpController.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using System.Security.Cryptography; using Bit.Core.Context; using Bit.Core.Exceptions; @@ -8,7 +11,7 @@ using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Tools.Controllers; +namespace Bit.Api.Dirt.Controllers; [Route("hibp")] [Authorize("Application")] diff --git a/src/Api/Dirt/Controllers/ReportsController.cs b/src/Api/Dirt/Controllers/ReportsController.cs index 4c0a802da2..e7c7e4a9bf 100644 --- a/src/Api/Dirt/Controllers/ReportsController.cs +++ b/src/Api/Dirt/Controllers/ReportsController.cs @@ -1,40 +1,53 @@ -using Bit.Api.Tools.Models; +using Bit.Api.Dirt.Models; +using Bit.Api.Dirt.Models.Response; using Bit.Api.Tools.Models.Response; using Bit.Core.Context; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Exceptions; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.ReportFeatures.Interfaces; -using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces; -using Bit.Core.Tools.ReportFeatures.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Tools.Controllers; +namespace Bit.Api.Dirt.Controllers; [Route("reports")] [Authorize("Application")] public class ReportsController : Controller { private readonly ICurrentContext _currentContext; - private readonly IMemberAccessCipherDetailsQuery _memberAccessCipherDetailsQuery; + private readonly IMemberAccessReportQuery _memberAccessReportQuery; + private readonly IRiskInsightsReportQuery _riskInsightsReportQuery; private readonly IAddPasswordHealthReportApplicationCommand _addPwdHealthReportAppCommand; private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery; private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand; + private readonly IAddOrganizationReportCommand _addOrganizationReportCommand; + private readonly IDropOrganizationReportCommand _dropOrganizationReportCommand; + private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; public ReportsController( ICurrentContext currentContext, - IMemberAccessCipherDetailsQuery memberAccessCipherDetailsQuery, + IMemberAccessReportQuery memberAccessReportQuery, + IRiskInsightsReportQuery riskInsightsReportQuery, IAddPasswordHealthReportApplicationCommand addPasswordHealthReportApplicationCommand, IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery, - IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand + IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand, + IGetOrganizationReportQuery getOrganizationReportQuery, + IAddOrganizationReportCommand addOrganizationReportCommand, + IDropOrganizationReportCommand dropOrganizationReportCommand ) { _currentContext = currentContext; - _memberAccessCipherDetailsQuery = memberAccessCipherDetailsQuery; + _memberAccessReportQuery = memberAccessReportQuery; + _riskInsightsReportQuery = riskInsightsReportQuery; _addPwdHealthReportAppCommand = addPasswordHealthReportApplicationCommand; _getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery; _dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand; + _getOrganizationReportQuery = getOrganizationReportQuery; + _addOrganizationReportCommand = addOrganizationReportCommand; + _dropOrganizationReportCommand = dropOrganizationReportCommand; } /// @@ -47,16 +60,16 @@ public class ReportsController : Controller [HttpGet("member-cipher-details/{orgId}")] public async Task> GetMemberCipherDetails(Guid orgId) { - // Using the AccessReports permission here until new permissions + // Using the AccessReports permission here until new permissions // are needed for more control over reports if (!await _currentContext.AccessReports(orgId)) { throw new NotFoundException(); } - var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId }); + var riskDetails = await GetRiskInsightsReportDetails(new RiskInsightsReportRequest { OrganizationId = orgId }); - var responses = memberCipherDetails.Select(x => new MemberCipherDetailsResponseModel(x)); + var responses = riskDetails.Select(x => new MemberCipherDetailsResponseModel(x)); return responses; } @@ -69,31 +82,46 @@ public class ReportsController : Controller /// IEnumerable of MemberAccessReportResponseModel /// If Access reports permission is not assigned [HttpGet("member-access/{orgId}")] - public async Task> GetMemberAccessReport(Guid orgId) + public async Task> GetMemberAccessReport(Guid orgId) { if (!await _currentContext.AccessReports(orgId)) { throw new NotFoundException(); } - var memberCipherDetails = await GetMemberCipherDetails(new MemberAccessCipherDetailsRequest { OrganizationId = orgId }); + var accessDetails = await GetMemberAccessDetails(new MemberAccessReportRequest { OrganizationId = orgId }); - var responses = memberCipherDetails.Select(x => new MemberAccessReportResponseModel(x)); + var responses = accessDetails.Select(x => new MemberAccessDetailReportResponseModel(x)); return responses; } /// - /// Contains the organization member info, the cipher ids associated with the member, + /// Contains the organization member info, the cipher ids associated with the member, /// and details on their collections, groups, and permissions /// - /// Request to the MemberAccessCipherDetailsQuery - /// IEnumerable of MemberAccessCipherDetails - private async Task> GetMemberCipherDetails(MemberAccessCipherDetailsRequest request) + /// Request parameters + /// + /// List of a user's permissions at a group and collection level as well as the number of ciphers + /// associated with that group/collection + /// + private async Task> GetMemberAccessDetails( + MemberAccessReportRequest request) { - var memberCipherDetails = - await _memberAccessCipherDetailsQuery.GetMemberAccessCipherDetails(request); - return memberCipherDetails; + var accessDetails = await _memberAccessReportQuery.GetMemberAccessReportsAsync(request); + return accessDetails; + } + + /// + /// Gets the risk insights report details from the risk insights query. Associates a user to their cipher ids + /// + /// Request parameters + /// A list of risk insights data associating the user to cipher ids + private async Task> GetRiskInsightsReportDetails( + RiskInsightsReportRequest request) + { + var riskDetails = await _riskInsightsReportQuery.GetRiskInsightsReportDetails(request); + return riskDetails; } /// @@ -185,4 +213,195 @@ public class ReportsController : Controller await _dropPwdHealthReportAppCommand.DropPasswordHealthReportApplicationAsync(request); } + + /// + /// Adds a new organization report + /// + /// A single instance of AddOrganizationReportRequest + /// A single instance of OrganizationReport + /// If user does not have access to the organization + /// If the organization Id is not valid + [HttpPost("organization-reports")] + public async Task AddOrganizationReport([FromBody] AddOrganizationReportRequest request) + { + if (!await _currentContext.AccessReports(request.OrganizationId)) + { + throw new NotFoundException(); + } + return await _addOrganizationReportCommand.AddOrganizationReportAsync(request); + } + + /// + /// Drops organization reports for an organization + /// + /// A single instance of DropOrganizationReportRequest + /// + /// If user does not have access to the organization + /// If the organization does not have any records + [HttpDelete("organization-reports")] + public async Task DropOrganizationReport([FromBody] DropOrganizationReportRequest request) + { + if (!await _currentContext.AccessReports(request.OrganizationId)) + { + throw new NotFoundException(); + } + await _dropOrganizationReportCommand.DropOrganizationReportAsync(request); + } + + /// + /// Gets organization reports for an organization + /// + /// A valid Organization Id + /// An Enumerable of OrganizationReport + /// If user does not have access to the organization + /// If the organization Id is not valid + [HttpGet("organization-reports/{orgId}")] + public async Task> GetOrganizationReports(Guid orgId) + { + if (!await _currentContext.AccessReports(orgId)) + { + throw new NotFoundException(); + } + return await _getOrganizationReportQuery.GetOrganizationReportAsync(orgId); + } + + /// + /// Gets the latest organization report for an organization + /// + /// A valid Organization Id + /// A single instance of OrganizationReport + /// If user does not have access to the organization + /// If the organization Id is not valid + [HttpGet("organization-reports/latest/{orgId}")] + public async Task GetLatestOrganizationReport(Guid orgId) + { + if (!await _currentContext.AccessReports(orgId)) + { + throw new NotFoundException(); + } + return await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(orgId); + } + + /// + /// Gets the Organization Report Summary for an organization. + /// This includes the latest report's encrypted data, encryption key, and date. + /// This is a mock implementation and should be replaced with actual data retrieval logic. + /// + /// + /// Min date (example: 2023-01-01) + /// Max date (example: 2023-12-31) + /// + /// + [HttpGet("organization-report-summary/{orgId}")] + public IEnumerable GetOrganizationReportSummary( + [FromRoute] Guid orgId, + [FromQuery] DateOnly from, + [FromQuery] DateOnly to) + { + if (!ModelState.IsValid) + { + throw new BadRequestException(ModelState); + } + + GuardOrganizationAccess(orgId); + + // FIXME: remove this mock class when actual data retrieval is implemented + return MockOrganizationReportSummary.GetMockData() + .Where(_ => _.OrganizationId == orgId + && _.Date >= from.ToDateTime(TimeOnly.MinValue) + && _.Date <= to.ToDateTime(TimeOnly.MaxValue)); + } + + /// + /// Creates a new Organization Report Summary for an organization. + /// This is a mock implementation and should be replaced with actual creation logic. + /// + /// + /// Returns 204 Created with the created OrganizationReportSummaryModel + /// + [HttpPost("organization-report-summary")] + public IActionResult CreateOrganizationReportSummary([FromBody] OrganizationReportSummaryModel model) + { + if (!ModelState.IsValid) + { + throw new BadRequestException(ModelState); + } + + GuardOrganizationAccess(model.OrganizationId); + + // TODO: Implement actual creation logic + + // Returns 204 No Content as a placeholder + return NoContent(); + } + + [HttpPut("organization-report-summary")] + public IActionResult UpdateOrganizationReportSummary([FromBody] OrganizationReportSummaryModel model) + { + if (!ModelState.IsValid) + { + throw new BadRequestException(ModelState); + } + + GuardOrganizationAccess(model.OrganizationId); + + // TODO: Implement actual update logic + + // Returns 204 No Content as a placeholder + return NoContent(); + } + + private void GuardOrganizationAccess(Guid organizationId) + { + if (!_currentContext.AccessReports(organizationId).Result) + { + throw new NotFoundException(); + } + } + + // FIXME: remove this mock class when actual data retrieval is implemented + private class MockOrganizationReportSummary + { + public static List GetMockData() + { + return new List + { + new OrganizationReportSummaryModel + { + OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), + EncryptedData = "2.EtCcxDEBoF1MYChYHC4Q1w==|RyZ07R7qEFBbc/ICLFpEMockL9K+PD6rOod6DGHHrkaRLHUDqDwmxbu3jnD0cg8s7GIYmp0jApHXC+82QdApk87pA0Kr8fN2Rj0+8bDQCjhKfoRTipAB25S/n2E+ttjvlFfag92S66XqUH9S/eZw/Q==|0bPfykHk3SqS/biLNcNoYtH6YTstBEKu3AhvdZZLxhU=", + EncryptionKey = "2.Dd/TtdNwxWdYg9+fRkxh6w==|8KAiK9SoadgFRmyVOchd4tNh2vErD1Rv9x1gqtsE5tzxKE/V/5kkr1WuVG+QpEj//YaQt221UEMESRSXicZ7a9cB6xXLBkbbFwmecQRJVBs=|902em44n9cwciZzYrYuX6MRzRa+4hh1HHfNAxyJx/IM=", + Date = DateTime.UtcNow + }, + new OrganizationReportSummaryModel + { + OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), + EncryptedData = "2.HvY4fAvbzYV1hqa3255m5Q==|WcKga2Wka5i8fVso8MgjzfBAwxaqdhZDL3bnvhDsisZ0r9lNKQcG3YUQSFpJxr74cgg5QRQaFieCUe2YppciHDT6bsaE2VzFce3cNNB821uTFqnlJClkGJpG1nGvPupdErrg4Ik57WenEzYesmR4pw==|F0aJfF+1MlPm+eAlQnDgFnwfv198N9VtPqFJa4+UFqk=", + EncryptionKey = "2.ctMgLN4ycPusbQArG/uiag==|NtqiQsAoUxMSTBQsxAMyVLWdt5lVEUGZQNxZSBU4l76ywH2f6dx5FWFrcF3t3GBqy5yDoc5eBg0VlJDW9coqzp8j9n8h1iMrtmXPyBMAhbc=|pbH+w68BUdUKYCfNRpjd8NENw2lZ0vfxgMuTrsrRCTQ=", + Date = DateTime.UtcNow.AddMonths(-1) + }, + new OrganizationReportSummaryModel + { + OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), + EncryptedData = "2.NH4qLZYUkz/+qpB/mRsLTA==|LEFt05jJz0ngh+Hl5lqk6kebj7lZMefA3eFdL1kLJSGdD3uTOngRwH7GXLQNFeQOxutnLX9YUILbUEPwaM8gCwNQ1KWYdB1Z+Ky4nzKRb60N7L5aTA2za6zXTIdjv7Zwhg0jPZ6sPevTuvSyqjMCuA==|Uuu6gZaF0wvB2mHFwtvHegMxfe8DgsYWTRfGiVn4lkM=", + EncryptionKey = "2.3YwG78ykSxAn44NcymdG4w==|4jfn0nLoFielicAFbmq27DNUUjV4SwGePnjYRmOa7hk4pEPnQRS3MsTJFbutVyXOgKFY9Yn2yGFZownY9EmXOMM+gHPD0t6TfzUKqQcRyuI=|wasP9zZEL9mFH5HzJYrMxnKUr/XlFKXCxG9uW66uaPU=", + Date = DateTime.UtcNow.AddMonths(-1) + }, + new OrganizationReportSummaryModel + { + OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), + EncryptedData = "2.YmKWj/707wDPONh+JXPBOw==|Fx4jcUHmnUnSMCU8vdThMSYpDyKPnC09TxpSbNxia0M6MFbd5WHElcVribrYgTENyU0HlqPW43hThJ6xXCM0EjEWP7/jb/0l07vMNkA7sDYq+czf0XnYZgZSGKh06wFVz8xkhaPTdsiO4CXuMsoH+w==|DDVwVFHzdfbPQe3ycCx82eYVHDW97V/eWTPsNpHX/+U=", + EncryptionKey = "2.f/U45I7KF+JKfnvOArUyaw==|zNhhS2q2WwBl6SqLWMkxrXC8EX91Ra9LJExywkJhsRbxubRLt7fK+YWc8T1LUaDmMwJ3G8buSPGzyacKX0lnUR33dW6DIaLNgRZ/ekb/zkg=|qFoIZWwS0foiiIOyikFRwQKmmmI2HeyHcOVklJnIILI=", + Date = DateTime.UtcNow.AddMonths(-1) + }, + new OrganizationReportSummaryModel + { + OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), + EncryptedData = "2.WYauwooJUEY3kZsDPphmrA==|oguYW6h10A4GxK4KkRS0X32qSTekU2CkGqNDNGfisUgvJzsyoVTafO9sVcdPdg4BUM7YNkPMjYiKEc5jMHkIgLzbnM27jcGvMJrrccSrLHiWL6/mEiqQkV3TlfiZF9i3wqj1ITsYRzM454uNle6Wrg==|uR67aFYb1i5LSidWib0iTf8091l8GY5olHkVXse3CAw=", + EncryptionKey = "2.ZyV9+9A2cxNaf8dfzfbnlA==|hhorBpVkcrrhTtNmd6SNHYI8gPNokGLOC22Vx8Qa/AotDAcyuYWw56zsawMnzpAdJGEJFtszKM2+VUVOcroCTMWHpy8yNf/kZA6uPk3Lz3s=|ASzVeJf+K1ZB8NXuypamRBGRuRq0GUHZBEy5r/O7ORY=", + Date = DateTime.UtcNow.AddMonths(-1) + }, + }; + } + } } diff --git a/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs b/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs index 93467e1175..0a57f0117e 100644 --- a/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs +++ b/src/Api/Dirt/Models/PasswordHealthReportApplicationModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.Tools.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.Dirt.Models; public class PasswordHealthReportApplicationModel { diff --git a/src/Api/Dirt/Models/Response/MemberAccessDetailReportResponseModel.cs b/src/Api/Dirt/Models/Response/MemberAccessDetailReportResponseModel.cs new file mode 100644 index 0000000000..2d5a7b1556 --- /dev/null +++ b/src/Api/Dirt/Models/Response/MemberAccessDetailReportResponseModel.cs @@ -0,0 +1,39 @@ +using Bit.Core.Dirt.Reports.Models.Data; + +namespace Bit.Api.Tools.Models.Response; + +public class MemberAccessDetailReportResponseModel +{ + public Guid? UserGuid { get; set; } + public string UserName { get; set; } + public string Email { get; set; } + public bool TwoFactorEnabled { get; set; } + public bool AccountRecoveryEnabled { get; set; } + public bool UsesKeyConnector { get; set; } + public Guid? CollectionId { get; set; } + public Guid? GroupId { get; set; } + public string GroupName { get; set; } + public string CollectionName { get; set; } + public bool? ReadOnly { get; set; } + public bool? HidePasswords { get; set; } + public bool? Manage { get; set; } + public IEnumerable CipherIds { get; set; } + + public MemberAccessDetailReportResponseModel(MemberAccessReportDetail reportDetail) + { + UserGuid = reportDetail.UserGuid; + UserName = reportDetail.UserName; + Email = reportDetail.Email; + TwoFactorEnabled = reportDetail.TwoFactorEnabled; + AccountRecoveryEnabled = reportDetail.AccountRecoveryEnabled; + UsesKeyConnector = reportDetail.UsesKeyConnector; + CollectionId = reportDetail.CollectionId; + GroupId = reportDetail.GroupId; + GroupName = reportDetail.GroupName; + CollectionName = reportDetail.CollectionName; + ReadOnly = reportDetail.ReadOnly; + HidePasswords = reportDetail.HidePasswords; + Manage = reportDetail.Manage; + CipherIds = reportDetail.CipherIds; + } +} diff --git a/src/Api/Dirt/Models/Response/MemberAccessReportModel.cs b/src/Api/Dirt/Models/Response/MemberAccessReportModel.cs index b110c316c1..38a5ed90d8 100644 --- a/src/Api/Dirt/Models/Response/MemberAccessReportModel.cs +++ b/src/Api/Dirt/Models/Response/MemberAccessReportModel.cs @@ -1,10 +1,10 @@ -using Bit.Core.Tools.Models.Data; +using Bit.Core.Dirt.Models.Data; -namespace Bit.Api.Tools.Models.Response; +namespace Bit.Api.Dirt.Models.Response; /// /// Contains the collections and group collections a user has access to including -/// the permission level for the collection and group collection. +/// the permission level for the collection and group collection. /// public class MemberAccessReportResponseModel { diff --git a/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs b/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs index d927da8123..886cf470db 100644 --- a/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs +++ b/src/Api/Dirt/Models/Response/MemberCipherDetailsResponseModel.cs @@ -1,6 +1,5 @@ -using Bit.Core.Tools.Models.Data; - -namespace Bit.Api.Tools.Models.Response; +using Bit.Core.Dirt.Reports.Models.Data; +namespace Bit.Api.Dirt.Models.Response; public class MemberCipherDetailsResponseModel { @@ -15,12 +14,12 @@ public class MemberCipherDetailsResponseModel /// public IEnumerable CipherIds { get; set; } - public MemberCipherDetailsResponseModel(MemberAccessCipherDetails memberAccessCipherDetails) + public MemberCipherDetailsResponseModel(RiskInsightsReportDetail reportDetail) { - this.UserGuid = memberAccessCipherDetails.UserGuid; - this.UserName = memberAccessCipherDetails.UserName; - this.Email = memberAccessCipherDetails.Email; - this.UsesKeyConnector = memberAccessCipherDetails.UsesKeyConnector; - this.CipherIds = memberAccessCipherDetails.CipherIds; + this.UserGuid = reportDetail.UserGuid; + this.UserName = reportDetail.UserName; + this.Email = reportDetail.Email; + this.UsesKeyConnector = reportDetail.UsesKeyConnector; + this.CipherIds = reportDetail.CipherIds; } } diff --git a/src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs new file mode 100644 index 0000000000..d912fb699e --- /dev/null +++ b/src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs @@ -0,0 +1,9 @@ +namespace Bit.Api.Dirt.Models.Response; + +public class OrganizationReportSummaryModel +{ + public Guid OrganizationId { get; set; } + public required string EncryptedData { get; set; } + public required string EncryptionKey { get; set; } + public DateTime Date { get; set; } +} diff --git a/src/Api/Dockerfile b/src/Api/Dockerfile index 6970dfa7bb..beacee89ae 100644 --- a/src/Api/Dockerfile +++ b/src/Api/Dockerfile @@ -1,21 +1,64 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +# We put the value in a file to be read by later layers. +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-musl-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-musl-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-musl-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/src/Api +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 + +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - krb5-user \ - && rm -rf /var/lib/apt/lists/* - -ENV ASPNETCORE_URLS http://+:5000 -WORKDIR /app +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -COPY obj/build-output/publish . -COPY entrypoint.sh / -RUN chmod +x /entrypoint.sh +RUN apk add --no-cache curl \ + krb5 \ + icu-libs \ + tzdata \ + shadow \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu + +# Copy app from the build stage +WORKDIR /app +COPY --from=build /source/src/Api/out /app +COPY ./src/Api/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/src/Api/Jobs/JobsHostedService.cs b/src/Api/Jobs/JobsHostedService.cs index 57b827a8be..0178f6d68b 100644 --- a/src/Api/Jobs/JobsHostedService.cs +++ b/src/Api/Jobs/JobsHostedService.cs @@ -1,4 +1,5 @@ -using Bit.Api.Auth.Jobs; +using Bit.Api.AdminConsole.Jobs; +using Bit.Api.Auth.Jobs; using Bit.Core.Jobs; using Bit.Core.Settings; using Quartz; @@ -65,6 +66,11 @@ public class JobsHostedService : BaseJobsHostedService .WithIntervalInHours(24) .RepeatForever()) .Build(); + var updateOrgSubscriptionsTrigger = TriggerBuilder.Create() + .WithIdentity("UpdateOrgSubscriptionsTrigger") + .StartNow() + .WithCronSchedule("0 0 */3 * * ?") // top of every 3rd hour + .Build(); var jobs = new List> @@ -76,6 +82,7 @@ public class JobsHostedService : BaseJobsHostedService new Tuple(typeof(ValidateOrganizationsJob), everyTwelfthHourAndThirtyMinutesTrigger), new Tuple(typeof(ValidateOrganizationDomainJob), validateOrganizationDomainTrigger), new Tuple(typeof(UpdatePhishingDomainsJob), updatePhishingDomainsTrigger), + new (typeof(OrganizationSubscriptionUpdateJob), updateOrgSubscriptionsTrigger), }; if (_globalSettings.SelfHosted && _globalSettings.EnableCloudCommunication) @@ -105,6 +112,7 @@ public class JobsHostedService : BaseJobsHostedService services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } public static void AddCommercialSecretsManagerJobServices(IServiceCollection services) diff --git a/src/Api/Jobs/SelfHostedSponsorshipSyncJob.cs b/src/Api/Jobs/SelfHostedSponsorshipSyncJob.cs index b4d9d75aa0..4e94cced03 100644 --- a/src/Api/Jobs/SelfHostedSponsorshipSyncJob.cs +++ b/src/Api/Jobs/SelfHostedSponsorshipSyncJob.cs @@ -1,9 +1,12 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Services; +using Bit.Core.Enums; using Bit.Core.Jobs; using Bit.Core.Models.OrganizationConnectionConfigs; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Quartz; diff --git a/src/Api/Jobs/ValidateOrganizationsJob.cs b/src/Api/Jobs/ValidateOrganizationsJob.cs index 8c4225a015..b027b4d049 100644 --- a/src/Api/Jobs/ValidateOrganizationsJob.cs +++ b/src/Api/Jobs/ValidateOrganizationsJob.cs @@ -1,5 +1,5 @@ -using Bit.Core.Jobs; -using Bit.Core.Services; +using Bit.Core.Billing.Services; +using Bit.Core.Jobs; using Quartz; namespace Bit.Api.Jobs; diff --git a/src/Api/Jobs/ValidateUsersJob.cs b/src/Api/Jobs/ValidateUsersJob.cs index be531b47de..351e141113 100644 --- a/src/Api/Jobs/ValidateUsersJob.cs +++ b/src/Api/Jobs/ValidateUsersJob.cs @@ -1,5 +1,5 @@ -using Bit.Core.Jobs; -using Bit.Core.Services; +using Bit.Core.Billing.Services; +using Bit.Core.Jobs; using Quartz; namespace Bit.Api.Jobs; diff --git a/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs index bac42bc302..9f52a97383 100644 --- a/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Api/Models/Public/CollectionBaseModel.cs b/src/Api/Models/Public/CollectionBaseModel.cs index 0dd4b6ce85..aff5485c31 100644 --- a/src/Api/Models/Public/CollectionBaseModel.cs +++ b/src/Api/Models/Public/CollectionBaseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Models.Public; diff --git a/src/Api/Models/Public/Request/CollectionUpdateRequestModel.cs b/src/Api/Models/Public/Request/CollectionUpdateRequestModel.cs index 0adc6afa77..fa8432fa04 100644 --- a/src/Api/Models/Public/Request/CollectionUpdateRequestModel.cs +++ b/src/Api/Models/Public/Request/CollectionUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Public.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Core.Entities; namespace Bit.Api.Models.Public.Request; diff --git a/src/Api/Models/Public/Response/CollectionResponseModel.cs b/src/Api/Models/Public/Response/CollectionResponseModel.cs index 58968d4be7..04ae565a27 100644 --- a/src/Api/Models/Public/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Public/Response/CollectionResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Core.Entities; using Bit.Core.Models.Data; diff --git a/src/Api/Models/Public/Response/ErrorResponseModel.cs b/src/Api/Models/Public/Response/ErrorResponseModel.cs index 4a4887a0e7..c5bb06d02e 100644 --- a/src/Api/Models/Public/Response/ErrorResponseModel.cs +++ b/src/Api/Models/Public/Response/ErrorResponseModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Api.Models.Public.Response; diff --git a/src/Api/Models/Request/Accounts/PremiumRequestModel.cs b/src/Api/Models/Request/Accounts/PremiumRequestModel.cs index 26d199381f..4e9882d67c 100644 --- a/src/Api/Models/Request/Accounts/PremiumRequestModel.cs +++ b/src/Api/Models/Request/Accounts/PremiumRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Settings; using Enums = Bit.Core.Enums; diff --git a/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs b/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs index f51580408a..5f58453a6d 100644 --- a/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs +++ b/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Models.Request.Accounts; diff --git a/src/Api/Models/Request/Accounts/UpdateAvatarRequestModel.cs b/src/Api/Models/Request/Accounts/UpdateAvatarRequestModel.cs index 2dd7b27945..225bccc4bf 100644 --- a/src/Api/Models/Request/Accounts/UpdateAvatarRequestModel.cs +++ b/src/Api/Models/Request/Accounts/UpdateAvatarRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; namespace Bit.Api.Models.Request.Accounts; diff --git a/src/Api/Models/Request/BitPayInvoiceRequestModel.cs b/src/Api/Models/Request/BitPayInvoiceRequestModel.cs index 66a5931ca0..d27736d712 100644 --- a/src/Api/Models/Request/BitPayInvoiceRequestModel.cs +++ b/src/Api/Models/Request/BitPayInvoiceRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Settings; namespace Bit.Api.Models.Request; diff --git a/src/Api/Models/Request/BulkCollectionAccessRequestModel.cs b/src/Api/Models/Request/BulkCollectionAccessRequestModel.cs index 8076d8ea5a..f0874cf987 100644 --- a/src/Api/Models/Request/BulkCollectionAccessRequestModel.cs +++ b/src/Api/Models/Request/BulkCollectionAccessRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.Models.Request; public class BulkCollectionAccessRequestModel { diff --git a/src/Api/Models/Request/CollectionRequestModel.cs b/src/Api/Models/Request/CollectionRequestModel.cs index 59fa0160a3..9aa80b859b 100644 --- a/src/Api/Models/Request/CollectionRequestModel.cs +++ b/src/Api/Models/Request/CollectionRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Utilities; diff --git a/src/Api/Models/Request/DeviceRequestModels.cs b/src/Api/Models/Request/DeviceRequestModels.cs index 99465501d9..397d4e27df 100644 --- a/src/Api/Models/Request/DeviceRequestModels.cs +++ b/src/Api/Models/Request/DeviceRequestModels.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.NotificationHub; diff --git a/src/Api/Models/Request/ExpandedTaxInfoUpdateRequestModel.cs b/src/Api/Models/Request/ExpandedTaxInfoUpdateRequestModel.cs index 7f95d755a5..5b526360f9 100644 --- a/src/Api/Models/Request/ExpandedTaxInfoUpdateRequestModel.cs +++ b/src/Api/Models/Request/ExpandedTaxInfoUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Request.Accounts; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Request.Accounts; namespace Bit.Api.Models.Request; diff --git a/src/Api/Models/Request/LicenseRequestModel.cs b/src/Api/Models/Request/LicenseRequestModel.cs index 7b66d95f0e..8851f71eaa 100644 --- a/src/Api/Models/Request/LicenseRequestModel.cs +++ b/src/Api/Models/Request/LicenseRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Models.Request; diff --git a/src/Api/Models/Request/Organizations/OrganizationCreateLicenseRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationCreateLicenseRequestModel.cs index 5ee7a632a6..13e0371c51 100644 --- a/src/Api/Models/Request/Organizations/OrganizationCreateLicenseRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationCreateLicenseRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Core.Utilities; diff --git a/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs index 896b5799e0..0dd2e892ac 100644 --- a/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationSponsorshipCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Enums; using Bit.Core.Utilities; diff --git a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs index 571f69c1ef..1278cd5b53 100644 --- a/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationUserResetPasswordRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Models.Request.Organizations; diff --git a/src/Api/Models/Request/PaymentRequestModel.cs b/src/Api/Models/Request/PaymentRequestModel.cs index eae1abfce2..4bc4a4d02b 100644 --- a/src/Api/Models/Request/PaymentRequestModel.cs +++ b/src/Api/Models/Request/PaymentRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Enums; namespace Bit.Api.Models.Request; diff --git a/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs b/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs index 318c40aa21..8630398e52 100644 --- a/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs +++ b/src/Api/Models/Request/SubscriptionCancellationRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.Models.Request; public class SubscriptionCancellationRequestModel { diff --git a/src/Api/Models/Request/UpdateDomainsRequestModel.cs b/src/Api/Models/Request/UpdateDomainsRequestModel.cs index 47c5d05dec..af53967267 100644 --- a/src/Api/Models/Request/UpdateDomainsRequestModel.cs +++ b/src/Api/Models/Request/UpdateDomainsRequestModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Api/Models/Response/CollectionResponseModel.cs b/src/Api/Models/Response/CollectionResponseModel.cs index d56ef5469a..d679250f05 100644 --- a/src/Api/Models/Response/CollectionResponseModel.cs +++ b/src/Api/Models/Response/CollectionResponseModel.cs @@ -1,4 +1,8 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Models.Data; @@ -18,12 +22,16 @@ public class CollectionResponseModel : ResponseModel OrganizationId = collection.OrganizationId; Name = collection.Name; ExternalId = collection.ExternalId; + Type = collection.Type; + DefaultUserCollectionEmail = collection.DefaultUserCollectionEmail; } public Guid Id { get; set; } public Guid OrganizationId { get; set; } public string Name { get; set; } public string ExternalId { get; set; } + public CollectionType Type { get; set; } + public string DefaultUserCollectionEmail { get; set; } } /// diff --git a/src/Api/Models/Response/ConfigResponseModel.cs b/src/Api/Models/Response/ConfigResponseModel.cs index 4571089295..d748254206 100644 --- a/src/Api/Models/Response/ConfigResponseModel.cs +++ b/src/Api/Models/Response/ConfigResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core; using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Services; diff --git a/src/Api/Models/Response/DeviceResponseModel.cs b/src/Api/Models/Response/DeviceResponseModel.cs index 44f8a16db2..4acaeea793 100644 --- a/src/Api/Models/Response/DeviceResponseModel.cs +++ b/src/Api/Models/Response/DeviceResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Utilities; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; diff --git a/src/Api/Models/Response/DomainsResponseModel.cs b/src/Api/Models/Response/DomainsResponseModel.cs index 5b6b4e59c8..82abddb4e4 100644 --- a/src/Api/Models/Response/DomainsResponseModel.cs +++ b/src/Api/Models/Response/DomainsResponseModel.cs @@ -1,14 +1,17 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; namespace Bit.Api.Models.Response; -public class DomainsResponseModel : ResponseModel +public class DomainsResponseModel() : ResponseModel("domains") { public DomainsResponseModel(User user, bool excluded = true) - : base("domains") + : this() { if (user == null) { @@ -35,13 +38,13 @@ public class DomainsResponseModel : ResponseModel public IEnumerable GlobalEquivalentDomains { get; set; } - public class GlobalDomains + public class GlobalDomains() { public GlobalDomains( GlobalEquivalentDomainsType globalDomain, IEnumerable domains, IEnumerable excludedDomains, - bool excluded) + bool excluded) : this() { Type = (byte)globalDomain; Domains = domains; diff --git a/src/Api/Models/Response/KeysResponseModel.cs b/src/Api/Models/Response/KeysResponseModel.cs index 2f7e5e7304..cfc1a6a0a1 100644 --- a/src/Api/Models/Response/KeysResponseModel.cs +++ b/src/Api/Models/Response/KeysResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Models.Api; namespace Bit.Api.Models.Response; diff --git a/src/Api/Models/Response/ListResponseModel.cs b/src/Api/Models/Response/ListResponseModel.cs index ecfe0a7e19..746e6c197b 100644 --- a/src/Api/Models/Response/ListResponseModel.cs +++ b/src/Api/Models/Response/ListResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; namespace Bit.Api.Models.Response; diff --git a/src/Api/Models/Response/PaymentResponseModel.cs b/src/Api/Models/Response/PaymentResponseModel.cs index 067ac969ec..1effe8bb1d 100644 --- a/src/Api/Models/Response/PaymentResponseModel.cs +++ b/src/Api/Models/Response/PaymentResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; namespace Bit.Api.Models.Response; diff --git a/src/Api/Models/Response/PlanResponseModel.cs b/src/Api/Models/Response/PlanResponseModel.cs index f48a06b4ec..6f2f752803 100644 --- a/src/Api/Models/Response/PlanResponseModel.cs +++ b/src/Api/Models/Response/PlanResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Models.Api; diff --git a/src/Api/Models/Response/ProfileResponseModel.cs b/src/Api/Models/Response/ProfileResponseModel.cs index 246b3c3227..cbdfaf0f16 100644 --- a/src/Api/Models/Response/ProfileResponseModel.cs +++ b/src/Api/Models/Response/ProfileResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.AdminConsole.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Response; using Bit.Api.AdminConsole.Models.Response.Providers; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.Entities; diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index c7aae1dec2..7038bee2a7 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -1,4 +1,8 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models.Business; +using Bit.Core.Entities; using Bit.Core.Models.Api; using Bit.Core.Models.Business; using Bit.Core.Utilities; diff --git a/src/Api/Models/Response/TaxInfoResponseModel.cs b/src/Api/Models/Response/TaxInfoResponseModel.cs index c1cd51267e..67896abac6 100644 --- a/src/Api/Models/Response/TaxInfoResponseModel.cs +++ b/src/Api/Models/Response/TaxInfoResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Business; namespace Bit.Api.Models.Response; diff --git a/src/Api/Platform/Installations/Models/InstallationRequestModel.cs b/src/Api/Platform/Installations/Models/InstallationRequestModel.cs index 242701a66f..2237eedf92 100644 --- a/src/Api/Platform/Installations/Models/InstallationRequestModel.cs +++ b/src/Api/Platform/Installations/Models/InstallationRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Platform.Installations; using Bit.Core.Utilities; diff --git a/src/Api/Platform/Installations/Models/InstallationResponseModel.cs b/src/Api/Platform/Installations/Models/InstallationResponseModel.cs index 0be5795275..c48a453426 100644 --- a/src/Api/Platform/Installations/Models/InstallationResponseModel.cs +++ b/src/Api/Platform/Installations/Models/InstallationResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.Platform.Installations; namespace Bit.Api.Platform.Installations; diff --git a/src/Api/Platform/Push/Controllers/PushController.cs b/src/Api/Platform/Push/Controllers/PushController.cs index 2a1f2b987d..88aec18be3 100644 --- a/src/Api/Platform/Push/Controllers/PushController.cs +++ b/src/Api/Platform/Push/Controllers/PushController.cs @@ -1,8 +1,14 @@ -using Bit.Core.Context; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Diagnostics; +using System.Text.Json; +using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Models.Api; using Bit.Core.NotificationHub; using Bit.Core.Platform.Push; +using Bit.Core.Platform.Push.Internal; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -20,14 +26,14 @@ namespace Bit.Api.Platform.Push; public class PushController : Controller { private readonly IPushRegistrationService _pushRegistrationService; - private readonly IPushNotificationService _pushNotificationService; + private readonly IPushRelayer _pushRelayer; private readonly IWebHostEnvironment _environment; private readonly ICurrentContext _currentContext; private readonly IGlobalSettings _globalSettings; public PushController( IPushRegistrationService pushRegistrationService, - IPushNotificationService pushNotificationService, + IPushRelayer pushRelayer, IWebHostEnvironment environment, ICurrentContext currentContext, IGlobalSettings globalSettings) @@ -35,7 +41,7 @@ public class PushController : Controller _currentContext = currentContext; _environment = environment; _pushRegistrationService = pushRegistrationService; - _pushNotificationService = pushNotificationService; + _pushRelayer = pushRelayer; _globalSettings = globalSettings; } @@ -74,31 +80,50 @@ public class PushController : Controller } [HttpPost("send")] - public async Task SendAsync([FromBody] PushSendRequestModel model) + public async Task SendAsync([FromBody] PushSendRequestModel model) { CheckUsage(); - if (!string.IsNullOrWhiteSpace(model.InstallationId)) + NotificationTarget target; + Guid targetId; + + if (model.InstallationId.HasValue) { - if (_currentContext.InstallationId!.Value.ToString() != model.InstallationId!) + if (_currentContext.InstallationId!.Value != model.InstallationId.Value) { throw new BadRequestException("InstallationId does not match current context."); } - await _pushNotificationService.SendPayloadToInstallationAsync( - _currentContext.InstallationId.Value.ToString(), model.Type, model.Payload, Prefix(model.Identifier), - Prefix(model.DeviceId), model.ClientType); + target = NotificationTarget.Installation; + targetId = _currentContext.InstallationId.Value; } - else if (!string.IsNullOrWhiteSpace(model.UserId)) + else if (model.UserId.HasValue) { - await _pushNotificationService.SendPayloadToUserAsync(Prefix(model.UserId), - model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType); + target = NotificationTarget.User; + targetId = model.UserId.Value; } - else if (!string.IsNullOrWhiteSpace(model.OrganizationId)) + else if (model.OrganizationId.HasValue) { - await _pushNotificationService.SendPayloadToOrganizationAsync(Prefix(model.OrganizationId), - model.Type, model.Payload, Prefix(model.Identifier), Prefix(model.DeviceId), model.ClientType); + target = NotificationTarget.Organization; + targetId = model.OrganizationId.Value; } + else + { + throw new UnreachableException("Model validation should have prevented getting here."); + } + + var notification = new RelayedNotification + { + Type = model.Type, + Target = target, + TargetId = targetId, + Payload = model.Payload, + Identifier = model.Identifier, + DeviceId = model.DeviceId, + ClientType = model.ClientType, + }; + + await _pushRelayer.RelayAsync(_currentContext.InstallationId.Value, notification); } private string Prefix(string value) diff --git a/src/Api/Program.cs b/src/Api/Program.cs index 2fd25eaefa..6023f51c6d 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -1,4 +1,7 @@ -using AspNetCoreRateLimit; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using AspNetCoreRateLimit; using Bit.Core.Utilities; using Microsoft.IdentityModel.Tokens; diff --git a/src/Api/Public/Controllers/CollectionsController.cs b/src/Api/Public/Controllers/CollectionsController.cs index b16eb1a418..8615113906 100644 --- a/src/Api/Public/Controllers/CollectionsController.cs +++ b/src/Api/Public/Controllers/CollectionsController.cs @@ -1,9 +1,13 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using Bit.Api.Models.Public.Request; using Bit.Api.Models.Public.Response; using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; using Bit.Core.Repositories; -using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -14,20 +18,17 @@ namespace Bit.Api.Public.Controllers; public class CollectionsController : Controller { private readonly ICollectionRepository _collectionRepository; - private readonly ICollectionService _collectionService; + private readonly IUpdateCollectionCommand _updateCollectionCommand; private readonly ICurrentContext _currentContext; - private readonly IApplicationCacheService _applicationCacheService; public CollectionsController( ICollectionRepository collectionRepository, - ICollectionService collectionService, - ICurrentContext currentContext, - IApplicationCacheService applicationCacheService) + IUpdateCollectionCommand updateCollectionCommand, + ICurrentContext currentContext) { _collectionRepository = collectionRepository; - _collectionService = collectionService; + _updateCollectionCommand = updateCollectionCommand; _currentContext = currentContext; - _applicationCacheService = applicationCacheService; } /// @@ -44,7 +45,8 @@ public class CollectionsController : Controller public async Task Get(Guid id) { (var collection, var access) = await _collectionRepository.GetByIdWithAccessAsync(id); - if (collection == null || collection.OrganizationId != _currentContext.OrganizationId) + if (collection == null || collection.OrganizationId != _currentContext.OrganizationId || + collection.Type == CollectionType.DefaultUserCollection) { return new NotFoundResult(); } @@ -63,7 +65,7 @@ public class CollectionsController : Controller [ProducesResponseType(typeof(ListResponseModel), (int)HttpStatusCode.OK)] public async Task List() { - var collections = await _collectionRepository.GetManyByOrganizationIdAsync( + var collections = await _collectionRepository.GetManySharedCollectionsByOrganizationIdAsync( _currentContext.OrganizationId.Value); // TODO: Get all CollectionGroup associations for the organization and marry them up here for the response. var collectionResponses = collections.Select(c => new CollectionResponseModel(c, null)); @@ -93,7 +95,7 @@ public class CollectionsController : Controller } var updatedCollection = model.ToCollection(existingCollection); var associations = model.Groups?.Select(c => c.ToCollectionAccessSelection()).ToList(); - await _collectionService.SaveAsync(updatedCollection, associations); + await _updateCollectionCommand.UpdateAsync(updatedCollection, associations, null); var response = new CollectionResponseModel(updatedCollection, associations); return new JsonResult(response); } @@ -115,6 +117,12 @@ public class CollectionsController : Controller { return new NotFoundResult(); } + + if (collection.Type == CollectionType.DefaultUserCollection) + { + return new BadRequestObjectResult(new ErrorResponseModel("You cannot delete a collection with the type as DefaultUserCollection.")); + } + await _collectionRepository.DeleteAsync(collection); return new OkResult(); } diff --git a/src/Api/SecretsManager/Controllers/ProjectsController.cs b/src/Api/SecretsManager/Controllers/ProjectsController.cs index a6929bc193..0af122fa57 100644 --- a/src/Api/SecretsManager/Controllers/ProjectsController.cs +++ b/src/Api/SecretsManager/Controllers/ProjectsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Context; diff --git a/src/Api/SecretsManager/Controllers/SecretsController.cs b/src/Api/SecretsManager/Controllers/SecretsController.cs index 9997e7502c..e32d5cd581 100644 --- a/src/Api/SecretsManager/Controllers/SecretsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsController.cs @@ -1,11 +1,13 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Identity; -using Bit.Core.Repositories; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Entities; @@ -16,9 +18,6 @@ using Bit.Core.SecretsManager.Queries.Interfaces; using Bit.Core.SecretsManager.Queries.Secrets.Interfaces; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -30,7 +29,6 @@ public class SecretsController : Controller private readonly ICurrentContext _currentContext; private readonly IProjectRepository _projectRepository; private readonly ISecretRepository _secretRepository; - private readonly IOrganizationRepository _organizationRepository; private readonly ICreateSecretCommand _createSecretCommand; private readonly IUpdateSecretCommand _updateSecretCommand; private readonly IDeleteSecretCommand _deleteSecretCommand; @@ -39,14 +37,12 @@ public class SecretsController : Controller private readonly ISecretAccessPoliciesUpdatesQuery _secretAccessPoliciesUpdatesQuery; private readonly IUserService _userService; private readonly IEventService _eventService; - private readonly IReferenceEventService _referenceEventService; private readonly IAuthorizationService _authorizationService; public SecretsController( ICurrentContext currentContext, IProjectRepository projectRepository, ISecretRepository secretRepository, - IOrganizationRepository organizationRepository, ICreateSecretCommand createSecretCommand, IUpdateSecretCommand updateSecretCommand, IDeleteSecretCommand deleteSecretCommand, @@ -55,13 +51,11 @@ public class SecretsController : Controller ISecretAccessPoliciesUpdatesQuery secretAccessPoliciesUpdatesQuery, IUserService userService, IEventService eventService, - IReferenceEventService referenceEventService, IAuthorizationService authorizationService) { _currentContext = currentContext; _projectRepository = projectRepository; _secretRepository = secretRepository; - _organizationRepository = organizationRepository; _createSecretCommand = createSecretCommand; _updateSecretCommand = updateSecretCommand; _deleteSecretCommand = deleteSecretCommand; @@ -70,7 +64,6 @@ public class SecretsController : Controller _secretAccessPoliciesUpdatesQuery = secretAccessPoliciesUpdatesQuery; _userService = userService; _eventService = eventService; - _referenceEventService = referenceEventService; _authorizationService = authorizationService; } @@ -119,7 +112,7 @@ public class SecretsController : Controller } var result = await _createSecretCommand.CreateAsync(secret, accessPoliciesUpdates); - + await LogSecretEventAsync(secret, EventType.Secret_Created); // Creating a secret means you have read & write permission. return new SecretResponseModel(result, true, true); } @@ -145,13 +138,7 @@ public class SecretsController : Controller throw new NotFoundException(); } - if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) - { - await _eventService.LogServiceAccountSecretEventAsync(userId, secret, EventType.Secret_Retrieved); - - var org = await _organizationRepository.GetByIdAsync(secret.OrganizationId); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.SmServiceAccountAccessedSecret, org, _currentContext)); - } + await LogSecretEventAsync(secret, EventType.Secret_Retrieved); return new SecretResponseModel(secret, access.Read, access.Write); } @@ -201,10 +188,10 @@ public class SecretsController : Controller { throw new NotFoundException(); } - } var result = await _updateSecretCommand.UpdateAsync(updatedSecret, accessPoliciesUpdates); + await LogSecretEventAsync(secret, EventType.Secret_Edited); // Updating a secret means you have read & write permission. return new SecretResponseModel(result, true, true); @@ -247,6 +234,7 @@ public class SecretsController : Controller await _deleteSecretCommand.DeleteSecrets(secretsToDelete); var responses = results.Select(r => new BulkDeleteResponseModel(r.Secret.Id, r.Error)); + await LogSecretsEventAsync(secretsToDelete, EventType.Secret_Deleted); return new ListResponseModel(responses); } @@ -266,7 +254,7 @@ public class SecretsController : Controller throw new NotFoundException(); } - await LogSecretsRetrievalAsync(secrets.First().OrganizationId, secrets); + await LogSecretsEventAsync(secrets, EventType.Secret_Retrieved); var responses = secrets.Select(s => new BaseSecretResponseModel(s)); return new ListResponseModel(responses); @@ -303,21 +291,28 @@ public class SecretsController : Controller if (syncResult.HasChanges) { - await LogSecretsRetrievalAsync(organizationId, syncResult.Secrets); + await LogSecretsEventAsync(syncResult.Secrets, EventType.Secret_Retrieved); } return new SecretsSyncResponseModel(syncResult.HasChanges, syncResult.Secrets); } - private async Task LogSecretsRetrievalAsync(Guid organizationId, IEnumerable secrets) + private async Task LogSecretsEventAsync(IEnumerable secrets, EventType eventType) { - if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) + var userId = _userService.GetProperUserId(User)!.Value; + + switch (_currentContext.IdentityClientType) { - var userId = _userService.GetProperUserId(User)!.Value; - var org = await _organizationRepository.GetByIdAsync(organizationId); - await _eventService.LogServiceAccountSecretsEventAsync(userId, secrets, EventType.Secret_Retrieved); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.SmServiceAccountAccessedSecret, org, _currentContext)); + case IdentityClientType.ServiceAccount: + await _eventService.LogServiceAccountSecretsEventAsync(userId, secrets, eventType); + break; + case IdentityClientType.User: + await _eventService.LogUserSecretsEventAsync(userId, secrets, eventType); + break; } } + + private Task LogSecretEventAsync(Secret secret, EventType eventType) => + LogSecretsEventAsync(new[] { secret }, eventType); + } diff --git a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs index 91d350b680..af162fe399 100644 --- a/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsManagerEventsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.Utilities; using Bit.Core.Exceptions; using Bit.Core.Models.Data; diff --git a/src/Api/SecretsManager/Controllers/SecretsManagerPortingController.cs b/src/Api/SecretsManager/Controllers/SecretsManagerPortingController.cs index 7599bd262b..7468586702 100644 --- a/src/Api/SecretsManager/Controllers/SecretsManagerPortingController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsManagerPortingController.cs @@ -1,4 +1,7 @@ -using Bit.Api.SecretsManager.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Context; using Bit.Core.Enums; diff --git a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs index 96c6c60528..499c496cc9 100644 --- a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs +++ b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; using Bit.Core.Billing.Pricing; diff --git a/src/Api/SecretsManager/Models/Request/AccessTokenCreateRequestModel.cs b/src/Api/SecretsManager/Models/Request/AccessTokenCreateRequestModel.cs index 2d961ad824..20014b6730 100644 --- a/src/Api/SecretsManager/Models/Request/AccessTokenCreateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/AccessTokenCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/GetSecretsRequestModel.cs b/src/Api/SecretsManager/Models/Request/GetSecretsRequestModel.cs index 5eec3a7a6c..84238ae149 100644 --- a/src/Api/SecretsManager/Models/Request/GetSecretsRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/GetSecretsRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.SecretsManager.Models.Request; public class GetSecretsRequestModel : IValidatableObject diff --git a/src/Api/SecretsManager/Models/Request/PeopleAccessPoliciesRequestModel.cs b/src/Api/SecretsManager/Models/Request/PeopleAccessPoliciesRequestModel.cs index 1ce74aca3c..d6f1396ed5 100644 --- a/src/Api/SecretsManager/Models/Request/PeopleAccessPoliciesRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/PeopleAccessPoliciesRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.SecretsManager.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.SecretsManager.Utilities; using Bit.Core.Exceptions; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Api/SecretsManager/Models/Request/ProjectCreateRequestModel.cs b/src/Api/SecretsManager/Models/Request/ProjectCreateRequestModel.cs index 3014ecdf82..73b8f0cdc9 100644 --- a/src/Api/SecretsManager/Models/Request/ProjectCreateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/ProjectCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/ProjectUpdateRequestModel.cs b/src/Api/SecretsManager/Models/Request/ProjectUpdateRequestModel.cs index 176b6cc598..a582e87d75 100644 --- a/src/Api/SecretsManager/Models/Request/ProjectUpdateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/ProjectUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/RequestSMAccessRequestModel.cs b/src/Api/SecretsManager/Models/Request/RequestSMAccessRequestModel.cs index 1f05bad933..b3a9e2a140 100644 --- a/src/Api/SecretsManager/Models/Request/RequestSMAccessRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/RequestSMAccessRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.SecretsManager.Models.Request; diff --git a/src/Api/SecretsManager/Models/Request/RevokeAccessTokensRequest.cs b/src/Api/SecretsManager/Models/Request/RevokeAccessTokensRequest.cs index ecced7a5cd..5dcce209fc 100644 --- a/src/Api/SecretsManager/Models/Request/RevokeAccessTokensRequest.cs +++ b/src/Api/SecretsManager/Models/Request/RevokeAccessTokensRequest.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; public class RevokeAccessTokensRequest { diff --git a/src/Api/SecretsManager/Models/Request/SMImportRequestModel.cs b/src/Api/SecretsManager/Models/Request/SMImportRequestModel.cs index a63e2c180d..a9ee6023bc 100644 --- a/src/Api/SecretsManager/Models/Request/SMImportRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/SMImportRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Commands.Porting; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/SecretCreateRequestModel.cs b/src/Api/SecretsManager/Models/Request/SecretCreateRequestModel.cs index 6c0d41c2dd..20cdcf005d 100644 --- a/src/Api/SecretsManager/Models/Request/SecretCreateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/SecretCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs b/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs index 7d298bfa0f..b95bc9e500 100644 --- a/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/SerivceAccountUpdateRequestModel.cs b/src/Api/SecretsManager/Models/Request/SerivceAccountUpdateRequestModel.cs index 017749725f..1c50ac059c 100644 --- a/src/Api/SecretsManager/Models/Request/SerivceAccountUpdateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/SerivceAccountUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Request/ServiceAccountCreateRequestModel.cs b/src/Api/SecretsManager/Models/Request/ServiceAccountCreateRequestModel.cs index 6771669209..ba27189281 100644 --- a/src/Api/SecretsManager/Models/Request/ServiceAccountCreateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/ServiceAccountCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.SecretsManager.Entities; using Bit.Core.Utilities; diff --git a/src/Api/SecretsManager/Models/Response/AccessTokenResponseModel.cs b/src/Api/SecretsManager/Models/Response/AccessTokenResponseModel.cs index 72f9fcac64..50fee5f976 100644 --- a/src/Api/SecretsManager/Models/Response/AccessTokenResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/AccessTokenResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; namespace Bit.Api.SecretsManager.Models.Response; diff --git a/src/Api/SecretsManager/Models/Response/BaseSecretResponseModel.cs b/src/Api/SecretsManager/Models/Response/BaseSecretResponseModel.cs index 0579baec07..26425b53d0 100644 --- a/src/Api/SecretsManager/Models/Response/BaseSecretResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/BaseSecretResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; namespace Bit.Api.SecretsManager.Models.Response; diff --git a/src/Api/SecretsManager/Models/Response/PotentialGranteeResponseModel.cs b/src/Api/SecretsManager/Models/Response/PotentialGranteeResponseModel.cs index 002ba1525b..9bc274430d 100644 --- a/src/Api/SecretsManager/Models/Response/PotentialGranteeResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/PotentialGranteeResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Api/SecretsManager/Models/Response/ProjectResponseModel.cs b/src/Api/SecretsManager/Models/Response/ProjectResponseModel.cs index c2a1b9a09f..f7b0bb5c9c 100644 --- a/src/Api/SecretsManager/Models/Response/ProjectResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/ProjectResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Api/SecretsManager/Models/Response/SMExportResponseModel.cs b/src/Api/SecretsManager/Models/Response/SMExportResponseModel.cs index 6d83117c32..c361e8abc3 100644 --- a/src/Api/SecretsManager/Models/Response/SMExportResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/SMExportResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; namespace Bit.Api.SecretsManager.Models.Response; diff --git a/src/Api/SecretsManager/Models/Response/SMImportResponseModel.cs b/src/Api/SecretsManager/Models/Response/SMImportResponseModel.cs index 25d9956c43..46e7422c77 100644 --- a/src/Api/SecretsManager/Models/Response/SMImportResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/SMImportResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Commands.Porting; namespace Bit.Api.SecretsManager.Models.Response; diff --git a/src/Api/SecretsManager/Models/Response/SecretWithProjectsListResponseModel.cs b/src/Api/SecretsManager/Models/Response/SecretWithProjectsListResponseModel.cs index 29dffa8e63..4f1e572a36 100644 --- a/src/Api/SecretsManager/Models/Response/SecretWithProjectsListResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/SecretWithProjectsListResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Api/SecretsManager/Models/Response/ServiceAccountResponseModel.cs b/src/Api/SecretsManager/Models/Response/ServiceAccountResponseModel.cs index 570c91fd08..17724d8fa0 100644 --- a/src/Api/SecretsManager/Models/Response/ServiceAccountResponseModel.cs +++ b/src/Api/SecretsManager/Models/Response/ServiceAccountResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Models.Data; diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index e24f96a7a9..699fa3f804 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -27,12 +27,11 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Api.Auth.Models.Request.WebAuthn; -using Bit.Api.Billing; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Tools.ImportFeatures; -using Bit.Core.Tools.ReportFeatures; using Bit.Core.Auth.Models.Api.Request; +using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Tools.SendFeatures; #if !OSS @@ -184,7 +183,6 @@ public class Startup services.AddImportServices(); services.AddPhishingDomainServices(globalSettings); - services.AddBillingQueries(); services.AddSendServices(); // Authorization Handlers diff --git a/src/Api/Tools/Authorization/VaultExportAuthorizationHandler.cs b/src/Api/Tools/Authorization/VaultExportAuthorizationHandler.cs index 337a0dc1e5..8968ecfee8 100644 --- a/src/Api/Tools/Authorization/VaultExportAuthorizationHandler.cs +++ b/src/Api/Tools/Authorization/VaultExportAuthorizationHandler.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.Context; using Bit.Core.Enums; using Microsoft.AspNetCore.Authorization; diff --git a/src/Api/Tools/Controllers/ImportCiphersController.cs b/src/Api/Tools/Controllers/ImportCiphersController.cs index 817105c74b..0f29a9aee3 100644 --- a/src/Api/Tools/Controllers/ImportCiphersController.cs +++ b/src/Api/Tools/Controllers/ImportCiphersController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Tools.Models.Request.Accounts; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Tools.Models.Request.Accounts; using Bit.Api.Tools.Models.Request.Organizations; using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core.Context; diff --git a/src/Api/Tools/Controllers/OrganizationExportController.cs b/src/Api/Tools/Controllers/OrganizationExportController.cs index 520746f139..b1925dd3cf 100644 --- a/src/Api/Tools/Controllers/OrganizationExportController.cs +++ b/src/Api/Tools/Controllers/OrganizationExportController.cs @@ -1,13 +1,11 @@ using Bit.Api.Tools.Authorization; using Bit.Api.Tools.Models.Response; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; -using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Vault.Queries; -using Bit.Core.Vault.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -17,33 +15,21 @@ namespace Bit.Api.Tools.Controllers; [Authorize("Application")] public class OrganizationExportController : Controller { - private readonly ICurrentContext _currentContext; private readonly IUserService _userService; - private readonly ICollectionService _collectionService; - private readonly ICipherService _cipherService; private readonly GlobalSettings _globalSettings; - private readonly IFeatureService _featureService; private readonly IAuthorizationService _authorizationService; private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly ICollectionRepository _collectionRepository; public OrganizationExportController( - ICurrentContext currentContext, - ICipherService cipherService, - ICollectionService collectionService, IUserService userService, GlobalSettings globalSettings, - IFeatureService featureService, IAuthorizationService authorizationService, IOrganizationCiphersQuery organizationCiphersQuery, ICollectionRepository collectionRepository) { - _currentContext = currentContext; - _cipherService = cipherService; - _collectionService = collectionService; _userService = userService; _globalSettings = globalSettings; - _featureService = featureService; _authorizationService = authorizationService; _organizationCiphersQuery = organizationCiphersQuery; _collectionRepository = collectionRepository; diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index b18e603c0f..43239b3995 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -1,11 +1,13 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Azure.Messaging.EventGrid; using Bit.Api.Models.Response; using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; using Bit.Api.Utilities; using Bit.Core; -using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Settings; @@ -33,7 +35,6 @@ public class SendsController : Controller private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; - private readonly ICurrentContext _currentContext; public SendsController( ISendRepository sendRepository, @@ -43,8 +44,7 @@ public class SendsController : Controller INonAnonymousSendCommand nonAnonymousSendCommand, ISendFileStorageService sendFileStorageService, ILogger logger, - GlobalSettings globalSettings, - ICurrentContext currentContext) + GlobalSettings globalSettings) { _sendRepository = sendRepository; _userService = userService; @@ -54,7 +54,6 @@ public class SendsController : Controller _sendFileStorageService = sendFileStorageService; _logger = logger; _globalSettings = globalSettings; - _currentContext = currentContext; } #region Anonymous endpoints diff --git a/src/Api/Tools/Models/Request/Accounts/ImportCiphersRequestModel.cs b/src/Api/Tools/Models/Request/Accounts/ImportCiphersRequestModel.cs index 354d73ad04..8330e4fc54 100644 --- a/src/Api/Tools/Models/Request/Accounts/ImportCiphersRequestModel.cs +++ b/src/Api/Tools/Models/Request/Accounts/ImportCiphersRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Vault.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Vault.Models.Request; namespace Bit.Api.Tools.Models.Request.Accounts; diff --git a/src/Api/Tools/Models/Request/Organizations/ImportOrganizationCiphersRequestModel.cs b/src/Api/Tools/Models/Request/Organizations/ImportOrganizationCiphersRequestModel.cs index 8c88be136a..45f8dfdffd 100644 --- a/src/Api/Tools/Models/Request/Organizations/ImportOrganizationCiphersRequestModel.cs +++ b/src/Api/Tools/Models/Request/Organizations/ImportOrganizationCiphersRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Request; using Bit.Api.Vault.Models.Request; namespace Bit.Api.Tools.Models.Request.Organizations; diff --git a/src/Api/Tools/Models/Request/SendAccessRequestModel.cs b/src/Api/Tools/Models/Request/SendAccessRequestModel.cs index c29577c2d0..15745ac855 100644 --- a/src/Api/Tools/Models/Request/SendAccessRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendAccessRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Tools.Models.Request; diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs index c15a3c4da9..9f24329bbd 100644 --- a/src/Api/Tools/Models/Request/SendRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json; using Bit.Core.Exceptions; using Bit.Core.Tools.Entities; diff --git a/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs index 5fd7e821cf..48fb96807e 100644 --- a/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs +++ b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Response; using Bit.Core.Entities; using Bit.Core.Models.Api; diff --git a/src/Api/Tools/Models/Response/SendAccessResponseModel.cs b/src/Api/Tools/Models/Response/SendAccessResponseModel.cs index 7e50c5e681..a466740d55 100644 --- a/src/Api/Tools/Models/Response/SendAccessResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendAccessResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Models.Api; using Bit.Core.Settings; using Bit.Core.Tools.Entities; diff --git a/src/Api/Tools/Models/Response/SendFileDownloadDataResponseModel.cs b/src/Api/Tools/Models/Response/SendFileDownloadDataResponseModel.cs index 47d5d3a840..8e20062301 100644 --- a/src/Api/Tools/Models/Response/SendFileDownloadDataResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendFileDownloadDataResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; namespace Bit.Api.Tools.Models.Response; diff --git a/src/Api/Tools/Models/Response/SendFileUploadDataResponseModel.cs b/src/Api/Tools/Models/Response/SendFileUploadDataResponseModel.cs index aee80de220..4f263b7e9c 100644 --- a/src/Api/Tools/Models/Response/SendFileUploadDataResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendFileUploadDataResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.Models.Api; namespace Bit.Api.Tools.Models.Response; diff --git a/src/Api/Tools/Models/Response/SendResponseModel.cs b/src/Api/Tools/Models/Response/SendResponseModel.cs index b50bf63185..aef4b5fa21 100644 --- a/src/Api/Tools/Models/Response/SendResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Models.Api; using Bit.Core.Settings; using Bit.Core.Tools.Entities; diff --git a/src/Api/Tools/Models/SendFileModel.cs b/src/Api/Tools/Models/SendFileModel.cs index 4af5b6ed6c..88deef4b13 100644 --- a/src/Api/Tools/Models/SendFileModel.cs +++ b/src/Api/Tools/Models/SendFileModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; diff --git a/src/Api/Tools/Models/SendTextModel.cs b/src/Api/Tools/Models/SendTextModel.cs index 274e0d537a..fdc547c522 100644 --- a/src/Api/Tools/Models/SendTextModel.cs +++ b/src/Api/Tools/Models/SendTextModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Tools.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; namespace Bit.Api.Tools.Models; diff --git a/src/Api/Utilities/ApiExplorerGroupConvention.cs b/src/Api/Utilities/ApiExplorerGroupConvention.cs index 42b1c8d6e7..e196b74617 100644 --- a/src/Api/Utilities/ApiExplorerGroupConvention.cs +++ b/src/Api/Utilities/ApiExplorerGroupConvention.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Mvc.ApplicationModels; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Mvc.ApplicationModels; namespace Bit.Api.Utilities; diff --git a/src/Api/Utilities/ApiHelpers.cs b/src/Api/Utilities/ApiHelpers.cs index f4f1830e16..3c0701b1bd 100644 --- a/src/Api/Utilities/ApiHelpers.cs +++ b/src/Api/Utilities/ApiHelpers.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Azure.Messaging.EventGrid; using Azure.Messaging.EventGrid.SystemEvents; using Bit.Core.Exceptions; @@ -62,9 +65,9 @@ public static class ApiHelpers } } - if (eventTypeHandlers.ContainsKey(eventGridEvent.EventType)) + if (eventTypeHandlers.TryGetValue(eventGridEvent.EventType, out var eventTypeHandler)) { - await eventTypeHandlers[eventGridEvent.EventType](eventGridEvent); + await eventTypeHandler(eventGridEvent); } } diff --git a/src/Api/Utilities/EnumMatchesAttribute.cs b/src/Api/Utilities/EnumMatchesAttribute.cs index a13b9d59d1..fb6a060170 100644 --- a/src/Api/Utilities/EnumMatchesAttribute.cs +++ b/src/Api/Utilities/EnumMatchesAttribute.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Utilities; diff --git a/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs b/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs index 15e8bb2954..91079d5040 100644 --- a/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs +++ b/src/Api/Utilities/ExceptionHandlerFilterAttribute.cs @@ -1,4 +1,7 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text; using Bit.Api.Models.Public.Response; using Bit.Core.Billing; using Bit.Core.Exceptions; diff --git a/src/Api/Utilities/MultipartFormDataHelper.cs b/src/Api/Utilities/MultipartFormDataHelper.cs index a3eb64efb8..a2ead1368a 100644 --- a/src/Api/Utilities/MultipartFormDataHelper.cs +++ b/src/Api/Utilities/MultipartFormDataHelper.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Api.Tools.Models.Request; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.WebUtilities; diff --git a/src/Api/Utilities/PublicApiControllersModelConvention.cs b/src/Api/Utilities/PublicApiControllersModelConvention.cs index a7fabb0319..473485a67c 100644 --- a/src/Api/Utilities/PublicApiControllersModelConvention.cs +++ b/src/Api/Utilities/PublicApiControllersModelConvention.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Mvc.ApplicationModels; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Mvc.ApplicationModels; namespace Bit.Api.Utilities; diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index e6a20fe364..4f123d3f4f 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -33,7 +33,12 @@ public static class ServiceCollectionExtensions Url = new Uri("https://bitwarden.com"), Email = "support@bitwarden.com" }, - Description = "The Bitwarden public APIs.", + Description = """ + This schema documents the endpoints available to the Public API, which provides + organizations tools for managing members, collections, groups, event logs, and policies. + If you are looking for the Vault Management API, refer instead to + [this document](https://bitwarden.com/help/vault-management-api/). + """, License = new OpenApiLicense { Name = "GNU Affero General Public License v3.0", diff --git a/src/Api/Utilities/StringMatchesAttribute.cs b/src/Api/Utilities/StringMatchesAttribute.cs new file mode 100644 index 0000000000..28485aed40 --- /dev/null +++ b/src/Api/Utilities/StringMatchesAttribute.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Utilities; + +public class StringMatchesAttribute(params string[]? accepted) : ValidationAttribute +{ + public override bool IsValid(object? value) + { + if (value is not string str || + accepted == null || + accepted.Length == 0) + { + return false; + } + + return accepted.Contains(str); + } +} diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 251362589e..761a5a3726 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Azure.Messaging.EventGrid; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Models.Response; @@ -42,7 +45,6 @@ public class CiphersController : Controller private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; - private readonly IFeatureService _featureService; private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly IApplicationCacheService _applicationCacheService; private readonly ICollectionRepository _collectionRepository; @@ -57,7 +59,6 @@ public class CiphersController : Controller ICurrentContext currentContext, ILogger logger, GlobalSettings globalSettings, - IFeatureService featureService, IOrganizationCiphersQuery organizationCiphersQuery, IApplicationCacheService applicationCacheService, ICollectionRepository collectionRepository) @@ -71,7 +72,6 @@ public class CiphersController : Controller _currentContext = currentContext; _logger = logger; _globalSettings = globalSettings; - _featureService = featureService; _organizationCiphersQuery = organizationCiphersQuery; _applicationCacheService = applicationCacheService; _collectionRepository = collectionRepository; @@ -157,6 +157,7 @@ public class CiphersController : Controller { if (model.EncryptedFor != user.Id) { + _logger.LogError("Cipher was not encrypted for the current user. CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", user.Id, model.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -186,6 +187,7 @@ public class CiphersController : Controller { if (model.Cipher.EncryptedFor != user.Id) { + _logger.LogError("Cipher was not encrypted for the current user. CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", user.Id, model.Cipher.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -218,6 +220,7 @@ public class CiphersController : Controller { if (model.Cipher.EncryptedFor != userId) { + _logger.LogError("Cipher was not encrypted for the current user. CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", userId, model.Cipher.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -244,6 +247,7 @@ public class CiphersController : Controller { if (model.EncryptedFor != user.Id) { + _logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", id, user.Id, model.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -281,6 +285,7 @@ public class CiphersController : Controller { if (model.EncryptedFor != userId) { + _logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", id, userId, model.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -375,11 +380,6 @@ public class CiphersController : Controller private async Task CanDeleteOrRestoreCipherAsAdminAsync(Guid organizationId, IEnumerable cipherIds) { - if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion)) - { - return await CanEditCipherAsAdminAsync(organizationId, cipherIds); - } - var org = _currentContext.GetOrganization(organizationId); // If we're not an "admin" or if we're a provider user we don't need to check the ciphers @@ -711,6 +711,7 @@ public class CiphersController : Controller { if (model.Cipher.EncryptedFor != user.Id) { + _logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId} CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", id, user.Id, model.Cipher.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } @@ -925,14 +926,14 @@ public class CiphersController : Controller public async Task PutDeleteAdmin(Guid id) { var userId = _userService.GetProperUserId(User).Value; - var cipher = await GetByIdAsync(id, userId); + var cipher = await GetByIdAsyncAdmin(id); if (cipher == null || !cipher.OrganizationId.HasValue || !await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) { throw new NotFoundException(); } - await _cipherService.SoftDeleteAsync(cipher, userId, true); + await _cipherService.SoftDeleteAsync(new CipherDetails(cipher), userId, true); } [HttpPut("delete")] @@ -994,14 +995,14 @@ public class CiphersController : Controller public async Task PutRestoreAdmin(Guid id) { var userId = _userService.GetProperUserId(User).Value; - var cipher = await GetByIdAsync(id, userId); + var cipher = await GetByIdAsyncAdmin(id); if (cipher == null || !cipher.OrganizationId.HasValue || !await CanDeleteOrRestoreCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) { throw new NotFoundException(); } - await _cipherService.RestoreAsync(cipher, userId, true); + await _cipherService.RestoreAsync(new CipherDetails(cipher), userId, true); return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp); } @@ -1064,7 +1065,7 @@ public class CiphersController : Controller [HttpPut("share")] [HttpPost("share")] - public async Task PutShareMany([FromBody] CipherBulkShareRequestModel model) + public async Task> PutShareMany([FromBody] CipherBulkShareRequestModel model) { var organizationId = new Guid(model.Ciphers.First().OrganizationId); if (!await _currentContext.OrganizationUser(organizationId)) @@ -1073,42 +1074,46 @@ public class CiphersController : Controller } var userId = _userService.GetProperUserId(User).Value; + var ciphers = await _cipherRepository.GetManyByUserIdAsync(userId, withOrganizations: false); var ciphersDict = ciphers.ToDictionary(c => c.Id); // Validate the model was encrypted for the posting user foreach (var cipher in model.Ciphers) { - if (cipher.EncryptedFor != null) + if (cipher.EncryptedFor.HasValue && cipher.EncryptedFor.Value != userId) { - if (cipher.EncryptedFor != userId) - { - throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); - } + _logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor); + throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } } - var shareCiphers = new List<(Cipher, DateTime?)>(); + var shareCiphers = new List<(CipherDetails, DateTime?)>(); foreach (var cipher in model.Ciphers) { - if (!ciphersDict.ContainsKey(cipher.Id.Value)) + if (!ciphersDict.TryGetValue(cipher.Id.Value, out var existingCipher)) { - throw new BadRequestException("Trying to move ciphers that you do not own."); + throw new BadRequestException("Trying to share ciphers that you do not own."); } - var existingCipher = ciphersDict[cipher.Id.Value]; - ValidateClientVersionForFido2CredentialSupport(existingCipher); - shareCiphers.Add((cipher.ToCipher(existingCipher), cipher.LastKnownRevisionDate)); + shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate)); } - await _cipherService.ShareManyAsync(shareCiphers, organizationId, - model.CollectionIds.Select(c => new Guid(c)), userId); + var updated = await _cipherService.ShareManyAsync( + shareCiphers, + organizationId, + model.CollectionIds.Select(Guid.Parse), + userId + ); + + var response = updated.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + return new ListResponseModel(response); } [HttpPost("purge")] - public async Task PostPurge([FromBody] SecretVerificationRequestModel model, string organizationId = null) + public async Task PostPurge([FromBody] SecretVerificationRequestModel model, Guid? organizationId = null) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -1123,24 +1128,22 @@ public class CiphersController : Controller throw new BadRequestException(ModelState); } - // Check if the user is claimed by any organization. - if (await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) - { - throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details."); - } - - if (string.IsNullOrWhiteSpace(organizationId)) + if (organizationId == null) { + // Check if the user is claimed by any organization. + if (await _userService.IsClaimedByAnyOrganizationAsync(user.Id)) + { + throw new BadRequestException("Cannot purge accounts owned by an organization. Contact your organization administrator for additional details."); + } await _cipherRepository.DeleteByUserIdAsync(user.Id); } else { - var orgId = new Guid(organizationId); - if (!await _currentContext.EditAnyCollection(orgId)) + if (!await _currentContext.EditAnyCollection(organizationId!.Value)) { throw new NotFoundException(); } - await _cipherService.PurgeAsync(orgId); + await _cipherService.PurgeAsync(organizationId!.Value); } } @@ -1186,14 +1189,14 @@ public class CiphersController : Controller var cipher = await GetByIdAsync(id, userId); var attachments = cipher?.GetAttachments(); - if (attachments == null || !attachments.ContainsKey(attachmentId) || attachments[attachmentId].Validated) + if (attachments == null || !attachments.TryGetValue(attachmentId, out var attachment) || attachment.Validated) { throw new NotFoundException(); } return new AttachmentUploadDataResponseModel { - Url = await _attachmentStorageService.GetAttachmentUploadUrlAsync(cipher, attachments[attachmentId]), + Url = await _attachmentStorageService.GetAttachmentUploadUrlAsync(cipher, attachment), FileUploadType = _attachmentStorageService.FileUploadType, }; } @@ -1212,11 +1215,10 @@ public class CiphersController : Controller var userId = _userService.GetProperUserId(User).Value; var cipher = await GetByIdAsync(id, userId); var attachments = cipher?.GetAttachments(); - if (attachments == null || !attachments.ContainsKey(attachmentId)) + if (attachments == null || !attachments.TryGetValue(attachmentId, out var attachmentData)) { throw new NotFoundException(); } - var attachmentData = attachments[attachmentId]; await Request.GetFileAsync(async (stream) => { @@ -1366,7 +1368,7 @@ public class CiphersController : Controller var cipher = await _cipherRepository.GetByIdAsync(new Guid(cipherId)); var attachments = cipher?.GetAttachments() ?? new Dictionary(); - if (cipher == null || !attachments.ContainsKey(attachmentId) || attachments[attachmentId].Validated) + if (cipher == null || !attachments.TryGetValue(attachmentId, out var attachment) || attachment.Validated) { if (_attachmentStorageService is AzureSendFileStorageService azureFileStorageService) { @@ -1376,7 +1378,7 @@ public class CiphersController : Controller return; } - await _cipherService.ValidateCipherAttachmentFile(cipher, attachments[attachmentId]); + await _cipherService.ValidateCipherAttachmentFile(cipher, attachment); } catch (Exception e) { @@ -1408,6 +1410,11 @@ public class CiphersController : Controller } } + private async Task GetByIdAsyncAdmin(Guid cipherId) + { + return await _cipherRepository.GetOrganizationDetailsByIdAsync(cipherId); + } + private async Task GetByIdAsync(Guid cipherId, Guid userId) { return await _cipherRepository.GetByIdAsync(cipherId, userId); diff --git a/src/Api/Vault/Controllers/FoldersController.cs b/src/Api/Vault/Controllers/FoldersController.cs index da9e6760c6..9da9e6a184 100644 --- a/src/Api/Vault/Controllers/FoldersController.cs +++ b/src/Api/Vault/Controllers/FoldersController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Response; using Bit.Core.Exceptions; diff --git a/src/Api/Vault/Controllers/SecurityTaskController.cs b/src/Api/Vault/Controllers/SecurityTaskController.cs index 2fe1025ba7..efff200e86 100644 --- a/src/Api/Vault/Controllers/SecurityTaskController.cs +++ b/src/Api/Vault/Controllers/SecurityTaskController.cs @@ -1,9 +1,10 @@ -using Bit.Api.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Response; -using Bit.Core; using Bit.Core.Services; -using Bit.Core.Utilities; using Bit.Core.Vault.Commands.Interfaces; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; @@ -15,7 +16,6 @@ namespace Bit.Api.Vault.Controllers; [Route("tasks")] [Authorize("Application")] -[RequireFeature(FeatureFlagKeys.SecurityTasks)] public class SecurityTaskController : Controller { private readonly IUserService _userService; @@ -24,6 +24,7 @@ public class SecurityTaskController : Controller private readonly IGetTasksForOrganizationQuery _getTasksForOrganizationQuery; private readonly ICreateManyTasksCommand _createManyTasksCommand; private readonly ICreateManyTaskNotificationsCommand _createManyTaskNotificationsCommand; + private readonly IGetTaskMetricsForOrganizationQuery _getTaskMetricsForOrganizationQuery; public SecurityTaskController( IUserService userService, @@ -31,7 +32,8 @@ public class SecurityTaskController : Controller IMarkTaskAsCompleteCommand markTaskAsCompleteCommand, IGetTasksForOrganizationQuery getTasksForOrganizationQuery, ICreateManyTasksCommand createManyTasksCommand, - ICreateManyTaskNotificationsCommand createManyTaskNotificationsCommand) + ICreateManyTaskNotificationsCommand createManyTaskNotificationsCommand, + IGetTaskMetricsForOrganizationQuery getTaskMetricsForOrganizationQuery) { _userService = userService; _getTaskDetailsForUserQuery = getTaskDetailsForUserQuery; @@ -39,6 +41,7 @@ public class SecurityTaskController : Controller _getTasksForOrganizationQuery = getTasksForOrganizationQuery; _createManyTasksCommand = createManyTasksCommand; _createManyTaskNotificationsCommand = createManyTaskNotificationsCommand; + _getTaskMetricsForOrganizationQuery = getTaskMetricsForOrganizationQuery; } /// @@ -80,6 +83,18 @@ public class SecurityTaskController : Controller return new ListResponseModel(response); } + /// + /// Retrieves security task metrics for an organization. + /// + /// The organization Id + [HttpGet("{organizationId:guid}/metrics")] + public async Task GetTaskMetricsForOrganization([FromRoute] Guid organizationId) + { + var metrics = await _getTaskMetricsForOrganizationQuery.GetTaskMetrics(organizationId); + + return new SecurityTaskMetricsResponseModel(metrics.CompletedTasks, metrics.TotalTasks); + } + /// /// Bulk create security tasks for an organization. /// diff --git a/src/Api/Vault/Controllers/SyncController.cs b/src/Api/Vault/Controllers/SyncController.cs index 568c05d651..54f1b9e70b 100644 --- a/src/Api/Vault/Controllers/SyncController.cs +++ b/src/Api/Vault/Controllers/SyncController.cs @@ -1,4 +1,7 @@ -using Bit.Api.Vault.Models.Response; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.Vault.Models.Response; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; diff --git a/src/Api/Vault/Models/CipherAttachmentModel.cs b/src/Api/Vault/Models/CipherAttachmentModel.cs index 1eadfc8ef5..381f66d37d 100644 --- a/src/Api/Vault/Models/CipherAttachmentModel.cs +++ b/src/Api/Vault/Models/CipherAttachmentModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; namespace Bit.Api.Vault.Models; diff --git a/src/Api/Vault/Models/CipherCardModel.cs b/src/Api/Vault/Models/CipherCardModel.cs index 5389de321e..e89dd51330 100644 --- a/src/Api/Vault/Models/CipherCardModel.cs +++ b/src/Api/Vault/Models/CipherCardModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/CipherFido2CredentialModel.cs b/src/Api/Vault/Models/CipherFido2CredentialModel.cs index 09d66a22e5..0133173171 100644 --- a/src/Api/Vault/Models/CipherFido2CredentialModel.cs +++ b/src/Api/Vault/Models/CipherFido2CredentialModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/CipherFieldModel.cs b/src/Api/Vault/Models/CipherFieldModel.cs index d51a766f7a..93abf9f647 100644 --- a/src/Api/Vault/Models/CipherFieldModel.cs +++ b/src/Api/Vault/Models/CipherFieldModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Utilities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/CipherIdentityModel.cs b/src/Api/Vault/Models/CipherIdentityModel.cs index ea32bab93d..6f70a3cc49 100644 --- a/src/Api/Vault/Models/CipherIdentityModel.cs +++ b/src/Api/Vault/Models/CipherIdentityModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/CipherLoginModel.cs b/src/Api/Vault/Models/CipherLoginModel.cs index 9580ebfed4..fc0aad14f8 100644 --- a/src/Api/Vault/Models/CipherLoginModel.cs +++ b/src/Api/Vault/Models/CipherLoginModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/CipherPasswordHistoryModel.cs b/src/Api/Vault/Models/CipherPasswordHistoryModel.cs index 6c70acb049..f9e9eff186 100644 --- a/src/Api/Vault/Models/CipherPasswordHistoryModel.cs +++ b/src/Api/Vault/Models/CipherPasswordHistoryModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/CipherSSHKeyModel.cs b/src/Api/Vault/Models/CipherSSHKeyModel.cs index 47853aa36e..850ffb656c 100644 --- a/src/Api/Vault/Models/CipherSSHKeyModel.cs +++ b/src/Api/Vault/Models/CipherSSHKeyModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Utilities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Utilities; using Bit.Core.Vault.Models.Data; namespace Bit.Api.Vault.Models; diff --git a/src/Api/Vault/Models/Request/AttachmentRequestModel.cs b/src/Api/Vault/Models/Request/AttachmentRequestModel.cs index e66cd56f29..96c66c6044 100644 --- a/src/Api/Vault/Models/Request/AttachmentRequestModel.cs +++ b/src/Api/Vault/Models/Request/AttachmentRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.Vault.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.Vault.Models.Request; public class AttachmentRequestModel { diff --git a/src/Api/Vault/Models/Request/BulkCreateSecurityTasksRequestModel.cs b/src/Api/Vault/Models/Request/BulkCreateSecurityTasksRequestModel.cs index 6c8c7e03b3..d269840298 100644 --- a/src/Api/Vault/Models/Request/BulkCreateSecurityTasksRequestModel.cs +++ b/src/Api/Vault/Models/Request/BulkCreateSecurityTasksRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Vault.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Vault.Models.Api; namespace Bit.Api.Vault.Models.Request; diff --git a/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs b/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs index 54d67995d2..59308dd496 100644 --- a/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherBulkUpdateCollectionsRequestModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Api.Vault.Models.Request; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Api.Vault.Models.Request; public class CipherBulkUpdateCollectionsRequestModel { diff --git a/src/Api/Vault/Models/Request/CipherPartialRequestModel.cs b/src/Api/Vault/Models/Request/CipherPartialRequestModel.cs index 6232f4ecf6..02977ca1fe 100644 --- a/src/Api/Vault/Models/Request/CipherPartialRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherPartialRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Vault.Models.Request; diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 5c288ab66d..187fd13e30 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Text.Json; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; @@ -113,18 +116,25 @@ public class CipherRequestModel if (hasAttachments2) { - foreach (var attachment in attachments.Where(a => Attachments2.ContainsKey(a.Key))) + foreach (var attachment in attachments) { - var attachment2 = Attachments2[attachment.Key]; + if (!Attachments2.TryGetValue(attachment.Key, out var attachment2)) + { + continue; + } attachment.Value.FileName = attachment2.FileName; attachment.Value.Key = attachment2.Key; } } else if (hasAttachments) { - foreach (var attachment in attachments.Where(a => Attachments.ContainsKey(a.Key))) + foreach (var attachment in attachments) { - attachment.Value.FileName = Attachments[attachment.Key]; + if (!Attachments.TryGetValue(attachment.Key, out var attachmentForKey)) + { + continue; + } + attachment.Value.FileName = attachmentForKey; attachment.Value.Key = null; } } diff --git a/src/Api/Vault/Models/Request/FolderRequestModel.cs b/src/Api/Vault/Models/Request/FolderRequestModel.cs index db9b65099f..27f34474be 100644 --- a/src/Api/Vault/Models/Request/FolderRequestModel.cs +++ b/src/Api/Vault/Models/Request/FolderRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; diff --git a/src/Api/Vault/Models/Response/AttachmentResponseModel.cs b/src/Api/Vault/Models/Response/AttachmentResponseModel.cs index f3c0261e98..4edebb539e 100644 --- a/src/Api/Vault/Models/Response/AttachmentResponseModel.cs +++ b/src/Api/Vault/Models/Response/AttachmentResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.Core.Vault.Entities; diff --git a/src/Api/Vault/Models/Response/AttachmentUploadDataResponseModel.cs b/src/Api/Vault/Models/Response/AttachmentUploadDataResponseModel.cs index 9eff417769..bb735ace4b 100644 --- a/src/Api/Vault/Models/Response/AttachmentUploadDataResponseModel.cs +++ b/src/Api/Vault/Models/Response/AttachmentUploadDataResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Bit.Core.Models.Api; namespace Bit.Api.Vault.Models.Response; diff --git a/src/Api/Vault/Models/Response/CipherPermissionsResponseModel.cs b/src/Api/Vault/Models/Response/CipherPermissionsResponseModel.cs index 4f2f7e86b2..b3082fc689 100644 --- a/src/Api/Vault/Models/Response/CipherPermissionsResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherPermissionsResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; using Bit.Core.Vault.Authorization.Permissions; using Bit.Core.Vault.Models.Data; diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index 358da3e62a..9d053f6697 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Entities; using Bit.Core.Models.Api; using Bit.Core.Models.Data.Organizations; @@ -129,13 +132,13 @@ public class CipherDetailsResponseModel : CipherResponseModel IDictionary> collectionCiphers, string obj = "cipherDetails") : base(cipher, user, organizationAbilities, globalSettings, obj) { - if (collectionCiphers?.ContainsKey(cipher.Id) ?? false) + if (collectionCiphers?.TryGetValue(cipher.Id, out var collectionCipher) ?? false) { - CollectionIds = collectionCiphers[cipher.Id].Select(c => c.CollectionId); + CollectionIds = collectionCipher.Select(c => c.CollectionId); } else { - CollectionIds = new Guid[] { }; + CollectionIds = []; } } @@ -147,7 +150,7 @@ public class CipherDetailsResponseModel : CipherResponseModel IEnumerable collectionCiphers, string obj = "cipherDetails") : base(cipher, user, organizationAbilities, globalSettings, obj) { - CollectionIds = collectionCiphers?.Select(c => c.CollectionId) ?? new List(); + CollectionIds = collectionCiphers?.Select(c => c.CollectionId) ?? []; } public CipherDetailsResponseModel( @@ -158,7 +161,7 @@ public class CipherDetailsResponseModel : CipherResponseModel string obj = "cipherDetails") : base(cipher, user, organizationAbilities, globalSettings, obj) { - CollectionIds = cipher.CollectionIds ?? new List(); + CollectionIds = cipher.CollectionIds ?? []; } public IEnumerable CollectionIds { get; set; } @@ -170,13 +173,13 @@ public class CipherMiniDetailsResponseModel : CipherMiniResponseModel IDictionary> collectionCiphers, bool orgUseTotp, string obj = "cipherMiniDetails") : base(cipher, globalSettings, orgUseTotp, obj) { - if (collectionCiphers?.ContainsKey(cipher.Id) ?? false) + if (collectionCiphers?.TryGetValue(cipher.Id, out var collectionCipher) ?? false) { - CollectionIds = collectionCiphers[cipher.Id].Select(c => c.CollectionId); + CollectionIds = collectionCipher.Select(c => c.CollectionId); } else { - CollectionIds = new Guid[] { }; + CollectionIds = []; } } @@ -184,7 +187,7 @@ public class CipherMiniDetailsResponseModel : CipherMiniResponseModel GlobalSettings globalSettings, bool orgUseTotp, string obj = "cipherMiniDetails") : base(cipher, globalSettings, orgUseTotp, obj) { - CollectionIds = cipher.CollectionIds ?? new List(); + CollectionIds = cipher.CollectionIds ?? []; } public CipherMiniDetailsResponseModel(CipherOrganizationDetailsWithCollections cipher, diff --git a/src/Api/Vault/Models/Response/FolderResponseModel.cs b/src/Api/Vault/Models/Response/FolderResponseModel.cs index 72ba08cb3b..21c25b19fe 100644 --- a/src/Api/Vault/Models/Response/FolderResponseModel.cs +++ b/src/Api/Vault/Models/Response/FolderResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Api; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Api; using Bit.Core.Vault.Entities; namespace Bit.Api.Vault.Models.Response; diff --git a/src/Api/Vault/Models/Response/SecurityTaskMetricsResponseModel.cs b/src/Api/Vault/Models/Response/SecurityTaskMetricsResponseModel.cs new file mode 100644 index 0000000000..502e90ddea --- /dev/null +++ b/src/Api/Vault/Models/Response/SecurityTaskMetricsResponseModel.cs @@ -0,0 +1,21 @@ +namespace Bit.Api.Vault.Models.Response; + +public class SecurityTaskMetricsResponseModel +{ + + public SecurityTaskMetricsResponseModel(int completedTasks, int totalTasks) + { + CompletedTasks = completedTasks; + TotalTasks = totalTasks; + } + + /// + /// Number of tasks that have been completed in the organization. + /// + public int CompletedTasks { get; set; } + + /// + /// Total number of tasks in the organization, regardless of their status. + /// + public int TotalTasks { get; set; } +} diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index b9da786567..e19defce51 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -1,9 +1,13 @@ -using Bit.Api.AdminConsole.Models.Response.Organizations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; using Bit.Api.Tools.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Response; using Bit.Core.Models.Api; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; @@ -15,7 +19,7 @@ using Bit.Core.Vault.Models.Data; namespace Bit.Api.Vault.Models.Response; -public class SyncResponseModel : ResponseModel +public class SyncResponseModel() : ResponseModel("sync") { public SyncResponseModel( GlobalSettings globalSettings, @@ -34,7 +38,7 @@ public class SyncResponseModel : ResponseModel bool excludeDomains, IEnumerable policies, IEnumerable sends) - : base("sync") + : this() { Profile = new ProfileResponseModel(user, organizationUserDetails, providerUserDetails, providerUserOrganizationDetails, userTwoFactorEnabled, userHasPremiumFromOrganization, organizationIdsClaimingingUser); @@ -51,6 +55,23 @@ public class SyncResponseModel : ResponseModel Domains = excludeDomains ? null : new DomainsResponseModel(user, false); Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List(); Sends = sends.Select(s => new SendResponseModel(s, globalSettings)); + UserDecryption = new UserDecryptionResponseModel + { + MasterPasswordUnlock = user.HasMasterPassword() + ? new MasterPasswordUnlockResponseModel + { + Kdf = new MasterPasswordUnlockKdfResponseModel + { + KdfType = user.Kdf, + Iterations = user.KdfIterations, + Memory = user.KdfMemory, + Parallelism = user.KdfParallelism + }, + MasterKeyEncryptedUserKey = user.Key!, + Salt = user.Email.ToLowerInvariant() + } + : null + }; } public ProfileResponseModel Profile { get; set; } @@ -60,4 +81,5 @@ public class SyncResponseModel : ResponseModel public DomainsResponseModel Domains { get; set; } public IEnumerable Policies { get; set; } public IEnumerable Sends { get; set; } + public UserDecryptionResponseModel UserDecryption { get; set; } } diff --git a/src/Api/entrypoint.sh b/src/Api/entrypoint.sh index 37d117215c..c4f31f1e5e 100644 --- a/src/Api/entrypoint.sh +++ b/src/Api/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # Setup @@ -19,31 +19,36 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -chown -R $USERNAME:$GROUPNAME /app -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then + chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos + fi + + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi -if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then - chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos - cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf - gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab +if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then + cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf + $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Api.dll +exec $gosu_cmd /app/Api diff --git a/src/Billing/.dockerignore b/src/Billing/.dockerignore deleted file mode 100644 index fc12f25146..0000000000 --- a/src/Billing/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -* -!obj/build-output/publish/* -!obj/Docker/empty/ -!entrypoint.sh diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 01a8bbdd9b..25327b17b7 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -6,11 +6,12 @@ + - + diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index ffe73808d4..5609879eeb 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -1,4 +1,7 @@ -namespace Bit.Billing; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Billing; public class BillingSettings { @@ -37,5 +40,6 @@ public class BillingSettings { public virtual string ApiKey { get; set; } public virtual string BaseUrl { get; set; } + public virtual int PersonaId { get; set; } } } diff --git a/src/Billing/Controllers/AppleController.cs b/src/Billing/Controllers/AppleController.cs index 1bcbbf2ad6..c08f1cfa61 100644 --- a/src/Billing/Controllers/AppleController.cs +++ b/src/Billing/Controllers/AppleController.cs @@ -1,4 +1,7 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text; using System.Text.Json; using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; @@ -28,8 +31,8 @@ public class AppleController : Controller return new BadRequestResult(); } - var key = HttpContext.Request.Query.ContainsKey("key") ? - HttpContext.Request.Query["key"].ToString() : null; + var key = HttpContext.Request.Query.TryGetValue("key", out var keyValue) ? + keyValue.ToString() : null; if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.AppleWebhookKey)) { return new BadRequestResult(); diff --git a/src/Billing/Controllers/BitPayController.cs b/src/Billing/Controllers/BitPayController.cs index a8d1742fcb..111ffabc2b 100644 --- a/src/Billing/Controllers/BitPayController.cs +++ b/src/Billing/Controllers/BitPayController.cs @@ -1,4 +1,7 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using Bit.Billing.Constants; using Bit.Billing.Models; using Bit.Core.AdminConsole.Repositories; diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs index 1fb0fb7ac7..3f26e28786 100644 --- a/src/Billing/Controllers/FreshdeskController.cs +++ b/src/Billing/Controllers/FreshdeskController.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Net.Http.Headers; using System.Reflection; using System.Text; @@ -141,7 +144,7 @@ public class FreshdeskController : Controller [HttpPost("webhook-onyx-ai")] public async Task PostWebhookOnyxAi([FromQuery, Required] string key, - [FromBody, Required] FreshdeskWebhookModel model) + [FromBody, Required] FreshdeskOnyxAiWebhookModel model) { // ensure that the key is from Freshdesk if (!IsValidRequestFromFreshdesk(key)) @@ -149,28 +152,8 @@ public class FreshdeskController : Controller return new BadRequestResult(); } - // get ticket info from Freshdesk - var getTicketRequest = new HttpRequestMessage(HttpMethod.Get, - string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}", model.TicketId)); - var getTicketResponse = await CallFreshdeskApiAsync(getTicketRequest); - - // check if we have a valid response from freshdesk - if (getTicketResponse.StatusCode != System.Net.HttpStatusCode.OK) - { - _logger.LogError("Error getting ticket info from Freshdesk. Ticket Id: {0}. Status code: {1}", - model.TicketId, getTicketResponse.StatusCode); - return BadRequest("Failed to retrieve ticket info from Freshdesk"); - } - - // extract info from the response - var ticketInfo = await ExtractTicketInfoFromResponse(getTicketResponse); - if (ticketInfo == null) - { - return BadRequest("Failed to extract ticket info from Freshdesk response"); - } - // create the onyx `answer-with-citation` request - var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(ticketInfo.DescriptionText); + var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx.PersonaId); var onyxRequest = new HttpRequestMessage(HttpMethod.Post, string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl)) { @@ -246,29 +229,6 @@ public class FreshdeskController : Controller } } - private async Task ExtractTicketInfoFromResponse(HttpResponseMessage getTicketResponse) - { - var responseString = string.Empty; - try - { - responseString = await getTicketResponse.Content.ReadAsStringAsync(); - var ticketInfo = JsonSerializer.Deserialize(responseString, - options: new System.Text.Json.JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - }); - - return ticketInfo; - } - catch (System.Exception ex) - { - _logger.LogError("Error deserializing ticket info from Freshdesk response. Response: {0}. Exception {1}", - responseString, ex.ToString()); - } - - return null; - } - private async Task CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0) { try diff --git a/src/Billing/Controllers/FreshsalesController.cs b/src/Billing/Controllers/FreshsalesController.cs index 0182011d7a..be5a9ddb16 100644 --- a/src/Billing/Controllers/FreshsalesController.cs +++ b/src/Billing/Controllers/FreshsalesController.cs @@ -1,4 +1,7 @@ -using System.Net.Http.Headers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net.Http.Headers; using System.Text.Json.Serialization; using Bit.Core.Billing.Enums; using Bit.Core.Repositories; diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index 2afde80601..8039680fd5 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -1,4 +1,7 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text; using Bit.Billing.Models; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Services; @@ -51,8 +54,8 @@ public class PayPalController : Controller [HttpPost("ipn")] public async Task PostIpn() { - var key = HttpContext.Request.Query.ContainsKey("key") - ? HttpContext.Request.Query["key"].ToString() + var key = HttpContext.Request.Query.TryGetValue("key", out var keyValue) + ? keyValue.ToString() : null; if (string.IsNullOrEmpty(key)) diff --git a/src/Billing/Controllers/RecoveryController.cs b/src/Billing/Controllers/RecoveryController.cs index bada1e826d..3f3dc4e650 100644 --- a/src/Billing/Controllers/RecoveryController.cs +++ b/src/Billing/Controllers/RecoveryController.cs @@ -1,4 +1,7 @@ -using Bit.Billing.Models.Recovery; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Models.Recovery; using Bit.Billing.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Http.HttpResults; diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 5ea2733a18..b60e0c56e4 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -1,4 +1,7 @@ -using Bit.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Models; using Bit.Billing.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; diff --git a/src/Billing/Dockerfile b/src/Billing/Dockerfile index 9abbe16477..1e182dedff 100644 --- a/src/Billing/Dockerfile +++ b/src/Billing/Dockerfile @@ -1,21 +1,63 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0 +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build +# Docker buildx supplies the value for this arg +ARG TARGETPLATFORM + +# Determine proper runtime value for .NET +# We put the value in a file to be read by later layers. +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + RID=linux-musl-x64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ + RID=linux-musl-arm64 ; \ + elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + RID=linux-musl-arm ; \ + fi \ + && echo "RID=$RID" > /tmp/rid.txt + +# Copy required project files +WORKDIR /source +COPY . ./ + +# Restore project dependencies and tools +WORKDIR /source/src/Billing +RUN . /tmp/rid.txt && dotnet restore -r $RID + +# Build project +RUN . /tmp/rid.txt && dotnet publish \ + -c release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r $RID \ + -o out + +############################################### +# App stage # +############################################### +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21 + +ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - gosu \ - curl \ - && rm -rf /var/lib/apt/lists/* - -ENV ASPNETCORE_URLS http://+:5000 -WORKDIR /app +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false EXPOSE 5000 -COPY entrypoint.sh / + +RUN apk add --no-cache curl \ + icu-libs \ + shadow \ + tzdata \ + && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu + +# Copy app from the build stage +WORKDIR /app +COPY --from=build /source/src/Billing/out /app +COPY ./src/Billing/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh - -COPY obj/build-output/publish . - HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/src/Billing/Jobs/SubscriptionCancellationJob.cs b/src/Billing/Jobs/SubscriptionCancellationJob.cs index b59bb10eaf..69b7bc876d 100644 --- a/src/Billing/Jobs/SubscriptionCancellationJob.cs +++ b/src/Billing/Jobs/SubscriptionCancellationJob.cs @@ -1,4 +1,7 @@ -using Bit.Billing.Services; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Services; using Bit.Core.Repositories; using Quartz; using Stripe; diff --git a/src/Billing/Models/BitPayEventModel.cs b/src/Billing/Models/BitPayEventModel.cs index e16391317a..008d4942a6 100644 --- a/src/Billing/Models/BitPayEventModel.cs +++ b/src/Billing/Models/BitPayEventModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Billing.Models; public class BitPayEventModel { diff --git a/src/Billing/Models/FreshdeskViewTicketModel.cs b/src/Billing/Models/FreshdeskViewTicketModel.cs deleted file mode 100644 index 2aa6eff94d..0000000000 --- a/src/Billing/Models/FreshdeskViewTicketModel.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace Bit.Billing.Models; - -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -public class FreshdeskViewTicketModel -{ - [JsonPropertyName("spam")] - public bool? Spam { get; set; } - - [JsonPropertyName("priority")] - public int? Priority { get; set; } - - [JsonPropertyName("source")] - public int? Source { get; set; } - - [JsonPropertyName("status")] - public int? Status { get; set; } - - [JsonPropertyName("subject")] - public string Subject { get; set; } - - [JsonPropertyName("support_email")] - public string SupportEmail { get; set; } - - [JsonPropertyName("id")] - public int Id { get; set; } - - [JsonPropertyName("description")] - public string Description { get; set; } - - [JsonPropertyName("description_text")] - public string DescriptionText { get; set; } - - [JsonPropertyName("created_at")] - public DateTime CreatedAt { get; set; } - - [JsonPropertyName("updated_at")] - public DateTime UpdatedAt { get; set; } - - [JsonPropertyName("tags")] - public List Tags { get; set; } -} diff --git a/src/Billing/Models/FreshdeskWebhookModel.cs b/src/Billing/Models/FreshdeskWebhookModel.cs index e9fe8e026a..aac0e9339d 100644 --- a/src/Billing/Models/FreshdeskWebhookModel.cs +++ b/src/Billing/Models/FreshdeskWebhookModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Billing.Models; @@ -13,3 +16,9 @@ public class FreshdeskWebhookModel [JsonPropertyName("ticket_tags")] public string TicketTags { get; set; } } + +public class FreshdeskOnyxAiWebhookModel : FreshdeskWebhookModel +{ + [JsonPropertyName("ticket_description_text")] + public string TicketDescriptionText { get; set; } +} diff --git a/src/Billing/Models/LoginModel.cs b/src/Billing/Models/LoginModel.cs index 5fe04ad454..f758dc8590 100644 --- a/src/Billing/Models/LoginModel.cs +++ b/src/Billing/Models/LoginModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Billing.Models; diff --git a/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs b/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs index e7bd29b2f5..ba3b89e297 100644 --- a/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs +++ b/src/Billing/Models/OnyxAnswerWithCitationRequestModel.cs @@ -1,4 +1,7 @@ - +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + + using System.Text.Json.Serialization; namespace Bit.Billing.Models; @@ -11,17 +14,15 @@ public class OnyxAnswerWithCitationRequestModel [JsonPropertyName("persona_id")] public int PersonaId { get; set; } = 1; - [JsonPropertyName("prompt_id")] - public int PromptId { get; set; } = 1; - [JsonPropertyName("retrieval_options")] public RetrievalOptions RetrievalOptions { get; set; } - public OnyxAnswerWithCitationRequestModel(string message) + public OnyxAnswerWithCitationRequestModel(string message, int personaId = 1) { message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' '); Messages = new List() { new Message() { MessageText = message } }; RetrievalOptions = new RetrievalOptions(); + PersonaId = personaId; } } @@ -41,9 +42,6 @@ public class RetrievalOptions [JsonPropertyName("real_time")] public bool RealTime { get; set; } = true; - - [JsonPropertyName("limit")] - public int? Limit { get; set; } = 3; } public class RetrievalOptionsRunSearch diff --git a/src/Billing/Models/OnyxAnswerWithCitationResponseModel.cs b/src/Billing/Models/OnyxAnswerWithCitationResponseModel.cs index e85ee9a674..5f67cd51d2 100644 --- a/src/Billing/Models/OnyxAnswerWithCitationResponseModel.cs +++ b/src/Billing/Models/OnyxAnswerWithCitationResponseModel.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Billing.Models; diff --git a/src/Billing/Models/PayPalIPNTransactionModel.cs b/src/Billing/Models/PayPalIPNTransactionModel.cs index 6fd0dfa0c4..34db5fdd04 100644 --- a/src/Billing/Models/PayPalIPNTransactionModel.cs +++ b/src/Billing/Models/PayPalIPNTransactionModel.cs @@ -1,4 +1,7 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using System.Runtime.InteropServices; using System.Web; diff --git a/src/Billing/Models/Recovery/EventsRequestBody.cs b/src/Billing/Models/Recovery/EventsRequestBody.cs index a40f8c9655..f3293cb48a 100644 --- a/src/Billing/Models/Recovery/EventsRequestBody.cs +++ b/src/Billing/Models/Recovery/EventsRequestBody.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Billing.Models.Recovery; diff --git a/src/Billing/Models/Recovery/EventsResponseBody.cs b/src/Billing/Models/Recovery/EventsResponseBody.cs index a0c7f087b7..a706734133 100644 --- a/src/Billing/Models/Recovery/EventsResponseBody.cs +++ b/src/Billing/Models/Recovery/EventsResponseBody.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Billing.Models.Recovery; diff --git a/src/Billing/Models/StripeWebhookDeliveryContainer.cs b/src/Billing/Models/StripeWebhookDeliveryContainer.cs index 6588aa7d13..9d566146fb 100644 --- a/src/Billing/Models/StripeWebhookDeliveryContainer.cs +++ b/src/Billing/Models/StripeWebhookDeliveryContainer.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; namespace Bit.Billing.Models; diff --git a/src/Billing/Program.cs b/src/Billing/Program.cs index 33e2665427..3e005ce7fd 100644 --- a/src/Billing/Program.cs +++ b/src/Billing/Program.cs @@ -20,8 +20,8 @@ public class Program return e.Level >= globalSettings.MinLogLevel.BillingSettings.Jobs; } - if (e.Properties.ContainsKey("RequestPath") && - !string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) && + if (e.Properties.TryGetValue("RequestPath", out var requestPath) && + !string.IsNullOrWhiteSpace(requestPath?.ToString()) && (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) { return false; diff --git a/src/Billing/Services/IStripeEventService.cs b/src/Billing/Services/IStripeEventService.cs index 6e2239cf98..bf242905ee 100644 --- a/src/Billing/Services/IStripeEventService.cs +++ b/src/Billing/Services/IStripeEventService.cs @@ -1,4 +1,7 @@ -using Stripe; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Stripe; namespace Bit.Billing.Services; diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index e53d901083..37ba51cc61 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -1,4 +1,8 @@ -using Stripe; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Stripe; +using Stripe.TestHelpers; namespace Bit.Billing.Services; @@ -95,4 +99,10 @@ public interface IStripeFacade string subscriptionId, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task GetTestClock( + string testClockId, + TestClockGetOptions testClockGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); } diff --git a/src/Billing/Services/Implementations/CustomerUpdatedHandler.cs b/src/Billing/Services/Implementations/CustomerUpdatedHandler.cs index 6deb0bc330..fe7745f760 100644 --- a/src/Billing/Services/Implementations/CustomerUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/CustomerUpdatedHandler.cs @@ -1,8 +1,4 @@ -using Bit.Core.Context; -using Bit.Core.Repositories; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; +using Bit.Core.Repositories; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -10,23 +6,17 @@ namespace Bit.Billing.Services.Implementations; public class CustomerUpdatedHandler : ICustomerUpdatedHandler { private readonly IOrganizationRepository _organizationRepository; - private readonly IReferenceEventService _referenceEventService; - private readonly ICurrentContext _currentContext; private readonly IStripeEventService _stripeEventService; private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly ILogger _logger; public CustomerUpdatedHandler( IOrganizationRepository organizationRepository, - IReferenceEventService referenceEventService, - ICurrentContext currentContext, IStripeEventService stripeEventService, IStripeEventUtilityService stripeEventUtilityService, ILogger logger) { _organizationRepository = organizationRepository ?? throw new ArgumentNullException(nameof(organizationRepository)); - _referenceEventService = referenceEventService; - _currentContext = currentContext; _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; _logger = logger; @@ -95,20 +85,5 @@ public class CustomerUpdatedHandler : ICustomerUpdatedHandler organization.BillingEmail = customer.Email; await _organizationRepository.ReplaceAsync(organization); - - if (_referenceEventService == null) - { - _logger.LogError("ReferenceEventService was not initialized in CustomerUpdatedHandler"); - throw new InvalidOperationException($"{nameof(_referenceEventService)} is not initialized"); - } - - if (_currentContext == null) - { - _logger.LogError("CurrentContext was not initialized in CustomerUpdatedHandler"); - throw new InvalidOperationException($"{nameof(_currentContext)} is not initialized"); - } - - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext)); } } diff --git a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs index 97bb29c35d..ee5a50cc98 100644 --- a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs +++ b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs @@ -1,4 +1,7 @@ -using Bit.Billing.Constants; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Constants; using Bit.Core; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index 40d8c8349d..4c256e3d85 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -3,13 +3,9 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Context; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -22,9 +18,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler private readonly IStripeFacade _stripeFacade; private readonly IProviderRepository _providerRepository; private readonly IOrganizationRepository _organizationRepository; - private readonly IReferenceEventService _referenceEventService; - private readonly ICurrentContext _currentContext; - private readonly IUserRepository _userRepository; private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationEnableCommand _organizationEnableCommand; @@ -36,9 +29,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler IStripeFacade stripeFacade, IProviderRepository providerRepository, IOrganizationRepository organizationRepository, - IReferenceEventService referenceEventService, - ICurrentContext currentContext, - IUserRepository userRepository, IStripeEventUtilityService stripeEventUtilityService, IUserService userService, IPushNotificationService pushNotificationService, @@ -50,9 +40,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler _stripeFacade = stripeFacade; _providerRepository = providerRepository; _organizationRepository = organizationRepository; - _referenceEventService = referenceEventService; - _currentContext = currentContext; - _userRepository = userRepository; _stripeEventUtilityService = stripeEventUtilityService; _userService = userService; _pushNotificationService = pushNotificationService; @@ -116,27 +103,7 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler _logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items", parsedEvent.Id, provider.Id); - - return; } - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent - { - Type = ReferenceEventType.Rebilled, - Source = ReferenceEventSource.Provider, - Id = provider.Id, - PlanType = PlanType.TeamsMonthly, - Seats = (int)teamsMonthlyLineItem.Quantity - }); - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent - { - Type = ReferenceEventType.Rebilled, - Source = ReferenceEventSource.Provider, - Id = provider.Id, - PlanType = PlanType.EnterpriseMonthly, - Seats = (int)enterpriseMonthlyLineItem.Quantity - }); } else if (organizationId.HasValue) { @@ -156,15 +123,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler await _organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); - - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Rebilled, organization, _currentContext) - { - PlanName = organization?.Plan, - PlanType = organization?.PlanType, - Seats = organization?.Seats, - Storage = organization?.MaxStorageGb, - }); } else if (userId.HasValue) { @@ -174,14 +132,6 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler } await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); - - var user = await _userRepository.GetByIdAsync(userId.Value); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Rebilled, user, _currentContext) - { - PlanName = IStripeEventUtilityService.PremiumPlanId, - Storage = user?.MaxStorageGb, - }); } } } diff --git a/src/Billing/Services/Implementations/ProviderEventService.cs b/src/Billing/Services/Implementations/ProviderEventService.cs index 1f6ef741df..12716c5aa2 100644 --- a/src/Billing/Services/Implementations/ProviderEventService.cs +++ b/src/Billing/Services/Implementations/ProviderEventService.cs @@ -1,4 +1,7 @@ -using Bit.Billing.Constants; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Constants; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs index 8d947e0ccb..7e2984e423 100644 --- a/src/Billing/Services/Implementations/StripeEventService.cs +++ b/src/Billing/Services/Implementations/StripeEventService.cs @@ -1,4 +1,7 @@ -using Bit.Billing.Constants; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Constants; using Bit.Core.Settings; using Stripe; diff --git a/src/Billing/Services/Implementations/StripeEventUtilityService.cs b/src/Billing/Services/Implementations/StripeEventUtilityService.cs index 48e81dee61..4c96bf977d 100644 --- a/src/Billing/Services/Implementations/StripeEventUtilityService.cs +++ b/src/Billing/Services/Implementations/StripeEventUtilityService.cs @@ -1,4 +1,7 @@ -using Bit.Billing.Constants; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Billing.Constants; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 191f84a343..726a3e977c 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -1,4 +1,9 @@ -using Stripe; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Stripe; +using Stripe.TestHelpers; +using CustomerService = Stripe.CustomerService; namespace Bit.Billing.Services.Implementations; @@ -11,6 +16,7 @@ public class StripeFacade : IStripeFacade private readonly PaymentMethodService _paymentMethodService = new(); private readonly SubscriptionService _subscriptionService = new(); private readonly DiscountService _discountService = new(); + private readonly TestClockService _testClockService = new(); public async Task GetCharge( string chargeId, @@ -116,4 +122,11 @@ public class StripeFacade : IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default) => await _discountService.DeleteSubscriptionDiscountAsync(subscriptionId, requestOptions, cancellationToken); + + public Task GetTestClock( + string testClockId, + TestClockGetOptions testClockGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + _testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken); } diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index fe5021c827..d5fcfb20d4 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,6 +1,10 @@ -using Bit.Billing.Constants; +using System.Globalization; +using Bit.Billing.Constants; using Bit.Billing.Jobs; +using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Push; @@ -8,6 +12,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Quartz; using Stripe; +using Stripe.TestHelpers; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -26,6 +31,10 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IPricingClient _pricingClient; + private readonly IFeatureService _featureService; + private readonly IProviderRepository _providerRepository; + private readonly IProviderService _providerService; + private readonly ILogger _logger; public SubscriptionUpdatedHandler( IStripeEventService stripeEventService, @@ -39,20 +48,30 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler ISchedulerFactory schedulerFactory, IOrganizationEnableCommand organizationEnableCommand, IOrganizationDisableCommand organizationDisableCommand, - IPricingClient pricingClient) + IPricingClient pricingClient, + IFeatureService featureService, + IProviderRepository providerRepository, + IProviderService providerService, + ILogger logger) { _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; _organizationService = organizationService; + _providerService = providerService; _stripeFacade = stripeFacade; _organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand; _userService = userService; _pushNotificationService = pushNotificationService; _organizationRepository = organizationRepository; + _providerRepository = providerRepository; _schedulerFactory = schedulerFactory; _organizationEnableCommand = organizationEnableCommand; _organizationDisableCommand = organizationDisableCommand; _pricingClient = pricingClient; + _featureService = featureService; + _providerRepository = providerRepository; + _providerService = providerService; + _logger = logger; } /// @@ -61,7 +80,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler /// public async Task HandleAsync(Event parsedEvent) { - var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice"]); + var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]); var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); switch (subscription.Status) @@ -77,6 +96,11 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } break; } + case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired when providerId.HasValue: + { + await HandleUnpaidProviderSubscriptionAsync(providerId.Value, parsedEvent, subscription); + break; + } case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired: { if (!userId.HasValue) @@ -105,13 +129,34 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } break; } + case StripeSubscriptionStatus.Active when providerId.HasValue: + { + var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); + if (!providerPortalTakeover) + { + break; + } + var provider = await _providerRepository.GetByIdAsync(providerId.Value); + if (provider != null) + { + provider.Enabled = true; + await _providerService.UpdateAsync(provider); + + if (IsProviderSubscriptionNowActive(parsedEvent, subscription)) + { + // Update the CancelAtPeriodEnd subscription option to prevent the now active provider subscription from being cancelled + var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }; + await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionUpdateOptions); + } + } + break; + } case StripeSubscriptionStatus.Active: { if (userId.HasValue) { await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); } - break; } } @@ -149,6 +194,36 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } } + /// + /// Checks if the provider subscription status has changed from a non-active to an active status type + /// If the previous status is already active(active,past-due,trialing),canceled,or null, then this will return false. + /// + /// The event containing the previous subscription status + /// The current subscription status + /// A boolean that represents whether the event status has changed from a non-active status to an active status + private static bool IsProviderSubscriptionNowActive(Event parsedEvent, Subscription subscription) + { + if (parsedEvent.Data.PreviousAttributes == null) + { + return false; + } + + var previousSubscription = parsedEvent + .Data + .PreviousAttributes + .ToObject() as Subscription; + + return previousSubscription?.Status switch + { + StripeSubscriptionStatus.IncompleteExpired + or StripeSubscriptionStatus.Paused + or StripeSubscriptionStatus.Incomplete + or StripeSubscriptionStatus.Unpaid + when subscription.Status == StripeSubscriptionStatus.Active => true, + _ => false + }; + } + /// /// Removes the Password Manager coupon if the organization is removing the Secrets Manager trial. /// Only applies to organizations that have a subscription from the Secrets Manager trial. @@ -238,4 +313,121 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler await scheduler.ScheduleJob(job, trigger); } + + private async Task HandleUnpaidProviderSubscriptionAsync( + Guid providerId, + Event parsedEvent, + Subscription currentSubscription) + { + var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); + + if (!providerPortalTakeover) + { + return; + } + + var provider = await _providerRepository.GetByIdAsync(providerId); + if (provider == null) + { + return; + } + + try + { + provider.Enabled = false; + await _providerService.UpdateAsync(provider); + + if (parsedEvent.Data.PreviousAttributes != null) + { + var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject() as Subscription; + + var updateIsSubscriptionGoingUnpaid = previousSubscription is + { + Status: + StripeSubscriptionStatus.Trialing or + StripeSubscriptionStatus.Active or + StripeSubscriptionStatus.PastDue + } && currentSubscription is + { + Status: StripeSubscriptionStatus.Unpaid, + LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create" + }; + + var updateIsManualSuspensionViaMetadata = CheckForManualSuspensionViaMetadata( + previousSubscription, currentSubscription); + + if (updateIsSubscriptionGoingUnpaid || updateIsManualSuspensionViaMetadata) + { + if (currentSubscription.TestClock != null) + { + await WaitForTestClockToAdvanceAsync(currentSubscription.TestClock); + } + + var now = currentSubscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + + var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) }; + + if (updateIsManualSuspensionViaMetadata) + { + subscriptionUpdateOptions.Metadata = new Dictionary + { + ["suspended_provider_via_webhook_at"] = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture) + }; + } + + await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions); + } + } + } + catch (Exception exception) + { + _logger.LogError(exception, "An error occurred while trying to disable and schedule subscription cancellation for provider ({ProviderID})", providerId); + } + } + + private async Task WaitForTestClockToAdvanceAsync(TestClock testClock) + { + while (testClock.Status != "ready") + { + await Task.Delay(TimeSpan.FromSeconds(2)); + testClock = await _stripeFacade.GetTestClock(testClock.Id); + if (testClock.Status == "internal_failure") + { + throw new Exception("Stripe Test Clock encountered an internal failure"); + } + } + } + + private static bool CheckForManualSuspensionViaMetadata( + Subscription? previousSubscription, + Subscription currentSubscription) + { + /* + * When metadata on a subscription is updated, we'll receive an event that has: + * Previous Metadata: { newlyAddedKey: null } + * Current Metadata: { newlyAddedKey: newlyAddedValue } + * + * As such, our check for a manual suspension must ensure that the 'previous_attributes' does contain the + * 'metadata' property, but also that the "suspend_provider" key in that metadata is set to null. + * + * If we don't do this and instead do a null coalescing check on 'previous_attributes?.metadata?.TryGetValue', + * we'll end up marking an event where 'previous_attributes.metadata' = null (which could be any subscription update + * that does not update the metadata) the same as a manual suspension. + */ + const string key = "suspend_provider"; + + if (previousSubscription is not { Metadata: not null } || + !previousSubscription.Metadata.TryGetValue(key, out var previousValue)) + { + return false; + } + + if (previousValue == null) + { + return !string.IsNullOrEmpty( + currentSubscription.Metadata.TryGetValue(key, out var currentValue) ? currentValue : null); + } + + return false; + } } diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index e31d1dceb7..323eaf5155 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,4 +1,7 @@ -using Bit.Core; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index afb01f4801..cfbc90c36e 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -1,7 +1,11 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using System.Net.Http.Headers; using Bit.Billing.Services; using Bit.Billing.Services.Implementations; +using Bit.Commercial.Core.Utilities; using Bit.Core.Billing.Extensions; using Bit.Core.Context; using Bit.Core.SecretsManager.Repositories; @@ -80,6 +84,7 @@ public class Startup services.AddDefaultServices(globalSettings); services.AddDistributedCache(globalSettings); services.AddBillingOperations(); + services.AddCommercialCoreServices(); services.TryAddSingleton(); diff --git a/src/Billing/appsettings.Development.json b/src/Billing/appsettings.Development.json index 32253a93c1..7c4889c22f 100644 --- a/src/Billing/appsettings.Development.json +++ b/src/Billing/appsettings.Development.json @@ -31,5 +31,10 @@ "storage": { "connectionString": "UseDevelopmentStorage=true" } + }, + "billingSettings": { + "onyx": { + "personaId": 68 + } } } diff --git a/src/Billing/appsettings.Production.json b/src/Billing/appsettings.Production.json index 819986181f..4be5d51a52 100644 --- a/src/Billing/appsettings.Production.json +++ b/src/Billing/appsettings.Production.json @@ -26,7 +26,10 @@ "payPal": { "production": true, "businessId": "4ZDA7DLUUJGMN" - } + }, + "onyx": { + "personaId": 7 + } }, "Logging": { "IncludeScopes": false, diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index 2a2864b246..aae25dde0b 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -76,7 +76,8 @@ }, "onyx": { "apiKey": "SECRET", - "baseUrl": "https://cloud.onyx.app/api" + "baseUrl": "https://cloud.onyx.app/api", + "personaId": 7 } } } diff --git a/src/Billing/entrypoint.sh b/src/Billing/entrypoint.sh index 6d98cfa6f6..8b6a312ea1 100644 --- a/src/Billing/entrypoint.sh +++ b/src/Billing/entrypoint.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # Setup @@ -19,25 +19,27 @@ then LGID=65534 fi -# Create user and group +if [ "$(id -u)" = "0" ] +then + # Create user and group -groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || -groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 -useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || -usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 -mkhomedir_helper $USERNAME + groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 || + groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1 + useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 || + usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 + mkhomedir_helper $USERNAME -# The rest... + # The rest... -chown -R $USERNAME:$GROUPNAME /app -mkdir -p /etc/bitwarden/core -mkdir -p /etc/bitwarden/logs -mkdir -p /etc/bitwarden/ca-certificates -chown -R $USERNAME:$GROUPNAME /etc/bitwarden + chown -R $USERNAME:$GROUPNAME /app + mkdir -p /etc/bitwarden/core + mkdir -p /etc/bitwarden/logs + mkdir -p /etc/bitwarden/ca-certificates + chown -R $USERNAME:$GROUPNAME /etc/bitwarden -if [[ $globalSettings__selfHosted == "true" ]]; then - cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \ - && update-ca-certificates + gosu_cmd="gosu $USERNAME:$GROUPNAME" +else + gosu_cmd="" fi -exec gosu $USERNAME:$GROUPNAME dotnet /app/Billing.dll +exec $gosu_cmd /app/Billing diff --git a/src/Core/AdminConsole/Context/CurrentContextProvider.cs b/src/Core/AdminConsole/Context/CurrentContextProvider.cs index 78a5565e80..5be25171d0 100644 --- a/src/Core/AdminConsole/Context/CurrentContextProvider.cs +++ b/src/Core/AdminConsole/Context/CurrentContextProvider.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Models.Data; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index e649406bb0..3f02462501 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -4,18 +4,17 @@ using System.Text.Json; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Business; using Bit.Core.Services; -using Bit.Core.Tools.Entities; using Bit.Core.Utilities; #nullable enable namespace Bit.Core.AdminConsole.Entities; -public class Organization : ITableObject, IStorableSubscriber, IRevisable, IReferenceable +public class Organization : ITableObject, IStorableSubscriber, IRevisable { private Dictionary? _twoFactorProviders; @@ -124,6 +123,11 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, /// public bool UseAdminSponsoredFamilies { get; set; } + /// + /// If set to true, organization needs their seat count synced with their subscription + /// + public bool SyncSeats { get; set; } + public void SetNewId() { if (Id == default(Guid)) @@ -258,12 +262,12 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, public bool TwoFactorProviderIsEnabled(TwoFactorProviderType provider) { var providers = GetTwoFactorProviders(); - if (providers == null || !providers.ContainsKey(provider)) + if (providers == null || !providers.TryGetValue(provider, out var twoFactorProvider)) { return false; } - return providers[provider].Enabled && Use2fa; + return twoFactorProvider.Enabled && Use2fa; } public bool TwoFactorIsEnabled() @@ -280,12 +284,7 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, public TwoFactorProvider? GetTwoFactorProvider(TwoFactorProviderType provider) { var providers = GetTwoFactorProviders(); - if (providers == null || !providers.ContainsKey(provider)) - { - return null; - } - - return providers[provider]; + return providers?.GetValueOrDefault(provider); } public void UpdateFromLicense(OrganizationLicense license, IFeatureService featureService) diff --git a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs b/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs index 25b669622f..52934cf7f3 100644 --- a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs @@ -10,10 +10,11 @@ public class OrganizationIntegrationConfiguration : ITableObject { public Guid Id { get; set; } public Guid OrganizationIntegrationId { get; set; } - public EventType EventType { get; set; } + public EventType? EventType { get; set; } public string? Configuration { get; set; } public string? Template { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public string? Filters { get; set; } public void SetNewId() => Id = CoreHelpers.GenerateComb(); } diff --git a/src/Core/AdminConsole/Entities/OrganizationUser.cs b/src/Core/AdminConsole/Entities/OrganizationUser.cs index 9828482a7e..3166ebf3a8 100644 --- a/src/Core/AdminConsole/Entities/OrganizationUser.cs +++ b/src/Core/AdminConsole/Entities/OrganizationUser.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Interfaces; using Bit.Core.Enums; using Bit.Core.Models; @@ -9,23 +10,75 @@ using Bit.Core.Utilities; namespace Bit.Core.Entities; +/// +/// An association table between one and one , representing that user's +/// membership in the organization. "Member" refers to the OrganizationUser object. +/// public class OrganizationUser : ITableObject, IExternal, IOrganizationUser { + /// + /// A unique random identifier. + /// public Guid Id { get; set; } + /// + /// The ID of the Organization that the user is a member of. + /// public Guid OrganizationId { get; set; } + /// + /// The ID of the User that is the member. This is NULL if the Status is Invited (or Invited and then Revoked), because + /// it is not linked to a specific User yet. + /// public Guid? UserId { get; set; } + /// + /// The email address of the user invited to the organization. This is NULL if the Status is not Invited (or + /// Invited and then Revoked), because in that case the OrganizationUser is linked to a User + /// and the email is stored on the User object. + /// [MaxLength(256)] public string? Email { get; set; } + /// + /// The Organization symmetric key encrypted with the User's public key. NULL if the user is not in a Confirmed + /// (or Confirmed and then Revoked) status. + /// public string? Key { get; set; } + /// + /// The User's symmetric key encrypted with the Organization's public key. NULL if the OrganizationUser + /// is not enrolled in account recovery. + /// public string? ResetPasswordKey { get; set; } + /// public OrganizationUserStatusType Status { get; set; } + /// + /// The User's role in the Organization. + /// public OrganizationUserType Type { get; set; } - + /// + /// An ID used to identify the OrganizationUser with an external directory service. Used by Directory Connector + /// and SCIM. + /// [MaxLength(300)] public string? ExternalId { get; set; } + /// + /// The date the OrganizationUser was created, i.e. when the User was first invited to the Organization. + /// public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; + /// + /// The last date the OrganizationUser entry was updated. + /// public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; + /// + /// A json blob representing the of the OrganizationUser if they + /// are a Custom user role (i.e. the is Custom). MAY be NULL if they are not + /// a custom user, but this is not guaranteed; do not use this to determine their role. + /// + /// + /// Avoid using this property directly - instead use the and + /// helper methods. + /// public string? Permissions { get; set; } + /// + /// True if the User has access to Secrets Manager for this Organization, false otherwise. + /// public bool AccessSecretsManager { get; set; } public void SetNewId() diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/AdminConsole/Enums/EventType.cs index 9d9cb09989..2359b922d8 100644 --- a/src/Core/AdminConsole/Enums/EventType.cs +++ b/src/Core/AdminConsole/Enums/EventType.cs @@ -90,4 +90,7 @@ public enum EventType : int OrganizationDomain_NotVerified = 2003, Secret_Retrieved = 2100, + Secret_Created = 2101, + Secret_Edited = 2102, + Secret_Deleted = 2103, } diff --git a/src/Core/AdminConsole/Enums/IntegrationType.cs b/src/Core/AdminConsole/Enums/IntegrationType.cs index 5edd54df23..58e55193dc 100644 --- a/src/Core/AdminConsole/Enums/IntegrationType.cs +++ b/src/Core/AdminConsole/Enums/IntegrationType.cs @@ -6,6 +6,7 @@ public enum IntegrationType : int Scim = 2, Slack = 3, Webhook = 4, + Hec = 5 } public static class IntegrationTypeExtensions @@ -18,6 +19,8 @@ public static class IntegrationTypeExtensions return "slack"; case IntegrationType.Webhook: return "webhook"; + case IntegrationType.Hec: + return "hec"; default: throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}"); } diff --git a/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs b/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs index 576e98ea74..3b4098715d 100644 --- a/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs +++ b/src/Core/AdminConsole/Enums/OrganizationUserStatusType.cs @@ -1,9 +1,34 @@ -namespace Bit.Core.Enums; +using Bit.Core.Entities; +namespace Bit.Core.Enums; + +/// +/// Represents the different stages of a member's lifecycle in an organization. +/// The object is populated differently depending on their Status. +/// public enum OrganizationUserStatusType : short { + /// + /// The OrganizationUser entry only represents an invitation to join the organization. It is not linked to a + /// specific User yet. + /// Invited = 0, + /// + /// The User has accepted the invitation and linked their User account to the OrganizationUser entry. + /// Accepted = 1, + /// + /// An administrator has granted the User access to the organization. This is the final step in the User becoming + /// a "full" member of the organization, including a key exchange so that they can decrypt organization data. + /// Confirmed = 2, + /// + /// The OrganizationUser has been revoked from the organization and cannot access organization data while in this state. + /// + /// + /// An OrganizationUser may move into this status from any other status, and will move back to their original status + /// if restored. This allows an administrator to easily suspend and restore access without going through the + /// Invite flow again. + /// Revoked = -1, } diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index 6f3bcd0102..ab39e543f8 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -7,7 +7,7 @@ public enum PolicyType : byte PasswordGenerator = 2, SingleOrg = 3, RequireSso = 4, - PersonalOwnership = 5, + OrganizationDataOwnership = 5, DisableSend = 6, SendOptions = 7, ResetPassword = 8, @@ -17,6 +17,7 @@ public enum PolicyType : byte AutomaticAppLogIn = 12, FreeFamiliesSponsorshipPolicy = 13, RemoveUnlockWithPin = 14, + RestrictedItemTypesPolicy = 15, } public static class PolicyTypeExtensions @@ -34,7 +35,7 @@ public static class PolicyTypeExtensions PolicyType.PasswordGenerator => "Password generator", PolicyType.SingleOrg => "Single organization", PolicyType.RequireSso => "Require single sign-on authentication", - PolicyType.PersonalOwnership => "Remove individual vault", + PolicyType.OrganizationDataOwnership => "Enforce organization data ownership", PolicyType.DisableSend => "Remove Send", PolicyType.SendOptions => "Send options", PolicyType.ResetPassword => "Account recovery administration", @@ -43,7 +44,8 @@ public static class PolicyTypeExtensions PolicyType.ActivateAutofill => "Active auto-fill", PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications", PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship", - PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN" + PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN", + PolicyType.RestrictedItemTypesPolicy => "Restricted item types", }; } } diff --git a/src/Core/AdminConsole/Models/Business/ImportedGroup.cs b/src/Core/AdminConsole/Models/Business/ImportedGroup.cs index bd4e81bf5b..7e4cddb496 100644 --- a/src/Core/AdminConsole/Models/Business/ImportedGroup.cs +++ b/src/Core/AdminConsole/Models/Business/ImportedGroup.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; namespace Bit.Core.AdminConsole.Models.Business; diff --git a/src/Core/AdminConsole/Models/Business/ImportedOrganizationUser.cs b/src/Core/AdminConsole/Models/Business/ImportedOrganizationUser.cs index 967cdf253d..273d2ee3b3 100644 --- a/src/Core/AdminConsole/Models/Business/ImportedOrganizationUser.cs +++ b/src/Core/AdminConsole/Models/Business/ImportedOrganizationUser.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Business; public class ImportedOrganizationUser { diff --git a/src/Core/AdminConsole/Models/Business/InviteOrganization.cs b/src/Core/AdminConsole/Models/Business/InviteOrganization.cs index 175ee07a9f..56b3259bc4 100644 --- a/src/Core/AdminConsole/Models/Business/InviteOrganization.cs +++ b/src/Core/AdminConsole/Models/Business/InviteOrganization.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Models.StaticStore; namespace Bit.Core.AdminConsole.Models.Business; diff --git a/src/Core/AdminConsole/Models/Business/OrganizationUserInvite.cs b/src/Core/AdminConsole/Models/Business/OrganizationUserInvite.cs index e177a5047b..5b59102173 100644 --- a/src/Core/AdminConsole/Models/Business/OrganizationUserInvite.cs +++ b/src/Core/AdminConsole/Models/Business/OrganizationUserInvite.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; namespace Bit.Core.Models.Business; diff --git a/src/Core/AdminConsole/Models/Business/Provider/ProviderUserInvite.cs b/src/Core/AdminConsole/Models/Business/Provider/ProviderUserInvite.cs index 53cdefb3f9..061caffdd7 100644 --- a/src/Core/AdminConsole/Models/Business/Provider/ProviderUserInvite.cs +++ b/src/Core/AdminConsole/Models/Business/Provider/ProviderUserInvite.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums.Provider; namespace Bit.Core.AdminConsole.Models.Business.Provider; diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs new file mode 100644 index 0000000000..eff9f8e1be --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record HecIntegration(Uri Uri, string Scheme, string Token, string? Service = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecListenerConfiguration.cs new file mode 100644 index 0000000000..37a0d68beb --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecListenerConfiguration.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class HecListenerConfiguration(GlobalSettings globalSettings) + : ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration +{ + public IntegrationType IntegrationType + { + get => IntegrationType.Hec; + } + + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.HecEventsQueueName; + } + + public string IntegrationQueueName + { + get => _globalSettings.EventLogging.RabbitMq.HecIntegrationQueueName; + } + + public string IntegrationRetryQueueName + { + get => _globalSettings.EventLogging.RabbitMq.HecIntegrationRetryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.HecEventSubscriptionName; + } + + public string IntegrationSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.HecIntegrationSubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IEventListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IEventListenerConfiguration.cs new file mode 100644 index 0000000000..7b2dd1343e --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IEventListenerConfiguration.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public interface IEventListenerConfiguration +{ + public string EventQueueName { get; } + public string EventSubscriptionName { get; } + public string EventTopicName { get; } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationListenerConfiguration.cs new file mode 100644 index 0000000000..322a1cd952 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationListenerConfiguration.cs @@ -0,0 +1,18 @@ +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public interface IIntegrationListenerConfiguration : IEventListenerConfiguration +{ + public IntegrationType IntegrationType { get; } + public string IntegrationQueueName { get; } + public string IntegrationRetryQueueName { get; } + public string IntegrationSubscriptionName { get; } + public string IntegrationTopicName { get; } + public int MaxRetries { get; } + + public string RoutingKey + { + get => IntegrationType.ToRoutingKey(); + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs new file mode 100644 index 0000000000..f979b8af0e --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs @@ -0,0 +1,15 @@ +#nullable enable + +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public interface IIntegrationMessage +{ + IntegrationType IntegrationType { get; } + string MessageId { get; set; } + int RetryCount { get; } + DateTime? DelayUntilDate { get; } + void ApplyRetry(DateTime? handlerDelayUntilDate); + string ToJson(); +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs new file mode 100644 index 0000000000..bb0c2e01ba --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs @@ -0,0 +1,10 @@ +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class IntegrationFilterGroup +{ + public bool AndOperator { get; init; } = true; + public List? Rules { get; init; } + public List? Groups { get; init; } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs new file mode 100644 index 0000000000..f09df47738 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs @@ -0,0 +1,10 @@ +#nullable enable +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public enum IntegrationFilterOperation +{ + Equals = 0, + NotEquals = 1, + In = 2, + NotIn = 3 +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs new file mode 100644 index 0000000000..b9d90a0442 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs @@ -0,0 +1,11 @@ +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class IntegrationFilterRule +{ + public required string Property { get; set; } + public required IntegrationFilterOperation Operation { get; set; } + public required object? Value { get; set; } +} + diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs similarity index 84% rename from src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs rename to src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs index d2f0bde693..d3b0c0d5ac 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationHandlerResult.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.AdminConsole.Models.Data.Integrations; +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public class IntegrationHandlerResult { diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs similarity index 53% rename from src/Core/AdminConsole/Models/Data/Integrations/IntegrationMessage.cs rename to src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs index 1f288914d0..1861ec4522 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs @@ -1,13 +1,15 @@ -using System.Text.Json; +#nullable enable + +using System.Text.Json; using Bit.Core.Enums; -namespace Bit.Core.AdminConsole.Models.Data.Integrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; -public class IntegrationMessage : IIntegrationMessage +public class IntegrationMessage : IIntegrationMessage { public IntegrationType IntegrationType { get; set; } - public T Configuration { get; set; } - public string RenderedTemplate { get; set; } + public required string MessageId { get; set; } + public required string RenderedTemplate { get; set; } public int RetryCount { get; set; } = 0; public DateTime? DelayUntilDate { get; set; } @@ -22,12 +24,22 @@ public class IntegrationMessage : IIntegrationMessage DelayUntilDate = baseTime.AddSeconds(backoffSeconds + jitterSeconds); } - public string ToJson() + public virtual string ToJson() + { + return JsonSerializer.Serialize(this); + } +} + +public class IntegrationMessage : IntegrationMessage +{ + public required T Configuration { get; set; } + + public override string ToJson() { return JsonSerializer.Serialize(this); } - public static IntegrationMessage FromJson(string json) + public static IntegrationMessage? FromJson(string json) { return JsonSerializer.Deserialize>(json); } diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs similarity index 95% rename from src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs rename to src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs index 338c2b963d..82c236865f 100644 --- a/src/Core/AdminConsole/Models/Data/Integrations/IntegrationTemplateContext.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs @@ -5,7 +5,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; -namespace Bit.Core.AdminConsole.Models.Data.Integrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public class IntegrationTemplateContext(EventMessage eventMessage) { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/ListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/ListenerConfiguration.cs new file mode 100644 index 0000000000..662bb8241e --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/ListenerConfiguration.cs @@ -0,0 +1,28 @@ +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public abstract class ListenerConfiguration +{ + protected GlobalSettings _globalSettings; + + public ListenerConfiguration(GlobalSettings globalSettings) + { + _globalSettings = globalSettings; + } + + public int MaxRetries + { + get => _globalSettings.EventLogging.MaxRetries; + } + + public string EventTopicName + { + get => _globalSettings.EventLogging.AzureServiceBus.EventTopicName; + } + + public string IntegrationTopicName + { + get => _globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/RepositoryListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/RepositoryListenerConfiguration.cs new file mode 100644 index 0000000000..118b3a17fe --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/RepositoryListenerConfiguration.cs @@ -0,0 +1,17 @@ +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class RepositoryListenerConfiguration(GlobalSettings globalSettings) + : ListenerConfiguration(globalSettings), IEventListenerConfiguration +{ + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.EventRepositorySubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs new file mode 100644 index 0000000000..e8bfaee303 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record SlackIntegration(string Token); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs new file mode 100644 index 0000000000..2c757aeb76 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record SlackIntegrationConfiguration(string ChannelId); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs new file mode 100644 index 0000000000..6c3d4c2fff --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record SlackIntegrationConfigurationDetails(string ChannelId, string Token); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackListenerConfiguration.cs new file mode 100644 index 0000000000..7dd834f51e --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackListenerConfiguration.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class SlackListenerConfiguration(GlobalSettings globalSettings) : + ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration +{ + public IntegrationType IntegrationType + { + get => IntegrationType.Slack; + } + + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.SlackEventsQueueName; + } + + public string IntegrationQueueName + { + get => _globalSettings.EventLogging.RabbitMq.SlackIntegrationQueueName; + } + + public string IntegrationRetryQueueName + { + get => _globalSettings.EventLogging.RabbitMq.SlackIntegrationRetryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.SlackEventSubscriptionName; + } + + public string IntegrationSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.SlackIntegrationSubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs new file mode 100644 index 0000000000..84b4b97857 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record WebhookIntegration(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs new file mode 100644 index 0000000000..2f5e8d29c1 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record WebhookIntegrationConfiguration(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs new file mode 100644 index 0000000000..4fa1a67c8e --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record WebhookIntegrationConfigurationDetails(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookListenerConfiguration.cs new file mode 100644 index 0000000000..9d5bf811c7 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookListenerConfiguration.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class WebhookListenerConfiguration(GlobalSettings globalSettings) + : ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration +{ + public IntegrationType IntegrationType + { + get => IntegrationType.Webhook; + } + + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.WebhookEventsQueueName; + } + + public string IntegrationQueueName + { + get => _globalSettings.EventLogging.RabbitMq.WebhookIntegrationQueueName; + } + + public string IntegrationRetryQueueName + { + get => _globalSettings.EventLogging.RabbitMq.WebhookIntegrationRetryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.WebhookEventSubscriptionName; + } + + public string IntegrationSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.WebhookIntegrationSubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventMessage.cs b/src/Core/AdminConsole/Models/Data/EventMessage.cs index 6d2a1f2b4e..7c2c29f80f 100644 --- a/src/Core/AdminConsole/Models/Data/EventMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventMessage.cs @@ -1,4 +1,7 @@ -using Bit.Core.Context; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Context; using Bit.Core.Enums; namespace Bit.Core.Models.Data; diff --git a/src/Core/AdminConsole/Models/Data/EventTableEntity.cs b/src/Core/AdminConsole/Models/Data/EventTableEntity.cs index 7e863b128c..410ad67f0e 100644 --- a/src/Core/AdminConsole/Models/Data/EventTableEntity.cs +++ b/src/Core/AdminConsole/Models/Data/EventTableEntity.cs @@ -1,4 +1,7 @@ -using Azure; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Azure; using Azure.Data.Tables; using Bit.Core.Enums; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/Models/Data/GroupWithCollections.cs b/src/Core/AdminConsole/Models/Data/GroupWithCollections.cs index e9ba512574..6ec47990ae 100644 --- a/src/Core/AdminConsole/Models/Data/GroupWithCollections.cs +++ b/src/Core/AdminConsole/Models/Data/GroupWithCollections.cs @@ -1,4 +1,7 @@ -using System.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; using Bit.Core.AdminConsole.Entities; namespace Bit.Core.AdminConsole.Models.Data; diff --git a/src/Core/AdminConsole/Models/Data/IEvent.cs b/src/Core/AdminConsole/Models/Data/IEvent.cs index 6a177e39ca..7cdcf06eaf 100644 --- a/src/Core/AdminConsole/Models/Data/IEvent.cs +++ b/src/Core/AdminConsole/Models/Data/IEvent.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; namespace Bit.Core.Models.Data; diff --git a/src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs deleted file mode 100644 index bd1f280cad..0000000000 --- a/src/Core/AdminConsole/Models/Data/Integrations/IIntegrationMessage.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Bit.Core.Enums; - -namespace Bit.Core.AdminConsole.Models.Data.Integrations; - -public interface IIntegrationMessage -{ - IntegrationType IntegrationType { get; } - int RetryCount { get; set; } - DateTime? DelayUntilDate { get; set; } - void ApplyRetry(DateTime? handlerDelayUntilDate); - string ToJson(); -} diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs deleted file mode 100644 index 4fcce542ce..0000000000 --- a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegration.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Bit.Core.AdminConsole.Models.Data.Integrations; - -public record SlackIntegration(string token); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs deleted file mode 100644 index 2930004cbf..0000000000 --- a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfiguration.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Bit.Core.AdminConsole.Models.Data.Integrations; - -public record SlackIntegrationConfiguration(string channelId); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs deleted file mode 100644 index b81e50d403..0000000000 --- a/src/Core/AdminConsole/Models/Data/Integrations/SlackIntegrationConfigurationDetails.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Bit.Core.AdminConsole.Models.Data.Integrations; - -public record SlackIntegrationConfigurationDetails(string channelId, string token); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs deleted file mode 100644 index e8217d3ad3..0000000000 --- a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfiguration.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Bit.Core.AdminConsole.Models.Data.Integrations; - -public record WebhookIntegrationConfiguration(string url); diff --git a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs deleted file mode 100644 index e3e92c900f..0000000000 --- a/src/Core/AdminConsole/Models/Data/Integrations/WebhookIntegrationConfigurationDetails.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Bit.Core.AdminConsole.Models.Data.Integrations; - -public record WebhookIntegrationConfigurationDetails(string url); diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs index 139a7aff25..5fdc760c90 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationIntegrationConfigurationDetails.cs @@ -8,10 +8,12 @@ namespace Bit.Core.Models.Data.Organizations; public class OrganizationIntegrationConfigurationDetails { public Guid Id { get; set; } + public Guid OrganizationId { get; set; } public Guid OrganizationIntegrationId { get; set; } public IntegrationType IntegrationType { get; set; } - public EventType EventType { get; set; } + public EventType? EventType { get; set; } public string? Configuration { get; set; } + public string? Filters { get; set; } public string? IntegrationConfiguration { get; set; } public string? Template { get; set; } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationSubscriptionUpdate.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationSubscriptionUpdate.cs new file mode 100644 index 0000000000..ec66a6a94e --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationSubscriptionUpdate.cs @@ -0,0 +1,11 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.AdminConsole.Models.Data.Organizations; + +public record OrganizationSubscriptionUpdate +{ + public required Organization Organization { get; set; } + public int Seats => Organization.Seats ?? 0; + public Plan? Plan { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserInviteData.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserInviteData.cs index a48ee3a6c4..7f1034f50e 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserInviteData.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserInviteData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index 8de422ee31..bad06ccf64 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPolicyDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPolicyDetails.cs index 84939ecf79..0f5f5fd7c6 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPolicyDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPolicyDetails.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.Enums; namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPublicKey.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPublicKey.cs index 7c04967872..b51675bdc5 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPublicKey.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserPublicKey.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; public class OrganizationUserPublicKey { diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserResetPasswordDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserResetPasswordDetails.cs index 05d6807fad..f2ed0c0ba2 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserResetPasswordDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserResetPasswordDetails.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs index 64ee316ab6..6d182e197f 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Interfaces; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserWithCollections.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserWithCollections.cs index d86c6c1581..02d83597e2 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserWithCollections.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserWithCollections.cs @@ -1,4 +1,7 @@ -using System.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Data; using Bit.Core.Entities; namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; diff --git a/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs b/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs index f2f275b708..b66244ba5f 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/Policies/MasterPasswordPolicyData.cs @@ -1,20 +1,28 @@ -namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using System.Text.Json.Serialization; +namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies; public class MasterPasswordPolicyData : IPolicyDataModel { + [JsonPropertyName("minComplexity")] public int? MinComplexity { get; set; } + [JsonPropertyName("minLength")] public int? MinLength { get; set; } + [JsonPropertyName("requireLower")] public bool? RequireLower { get; set; } + [JsonPropertyName("requireUpper")] public bool? RequireUpper { get; set; } + [JsonPropertyName("requireNumbers")] public bool? RequireNumbers { get; set; } + [JsonPropertyName("requireSpecial")] public bool? RequireSpecial { get; set; } + [JsonPropertyName("enforceOnLogin")] public bool? EnforceOnLogin { get; set; } /// /// Combine the other policy data with this instance, taking the most secure options /// /// The other policy instance to combine with this - public void CombineWith(MasterPasswordPolicyData other) + public void CombineWith(MasterPasswordPolicyData? other) { if (other == null) { diff --git a/src/Core/AdminConsole/Models/Data/Organizations/Policies/OrganizationPolicyDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/Policies/OrganizationPolicyDetails.cs new file mode 100644 index 0000000000..eab0c9456f --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/Organizations/Policies/OrganizationPolicyDetails.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies; + +public class OrganizationPolicyDetails : PolicyDetails +{ + public Guid UserId { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index a6ad47f829..84ff164943 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -1,11 +1,14 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Models.Business; namespace Bit.Core.Models.Data.Organizations; diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationOrganizationDetails.cs index 9d84f60c4c..0a6d255774 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationOrganizationDetails.cs @@ -1,4 +1,7 @@ -using System.Net; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Net; using System.Text.Json.Serialization; using Bit.Core.Billing.Enums; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationProviderDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationProviderDetails.cs index 629e0bae53..77ca501526 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationProviderDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderOrganizationProviderDetails.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index 4621de8268..04281d098e 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs index 67565bad6d..2ab06bacae 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserProviderDetails.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserPublicKey.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserPublicKey.cs index a9b37b2050..18a0679702 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserPublicKey.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserPublicKey.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.AdminConsole.Models.Data.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.AdminConsole.Models.Data.Provider; public class ProviderUserPublicKey { diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserUserDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserUserDetails.cs index d42437a26e..97f72f7137 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserUserDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserUserDetails.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Utilities; diff --git a/src/Core/AdminConsole/Models/Mail/DeviceApprovalRequestedViewModel.cs b/src/Core/AdminConsole/Models/Mail/DeviceApprovalRequestedViewModel.cs index 7f6c932619..892d077296 100644 --- a/src/Core/AdminConsole/Models/Mail/DeviceApprovalRequestedViewModel.cs +++ b/src/Core/AdminConsole/Models/Mail/DeviceApprovalRequestedViewModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.AdminConsole.Models.Mail; diff --git a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs index 59debed746..ede2123f7e 100644 --- a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs +++ b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs @@ -1,4 +1,5 @@ - +#nullable enable + using System.Text.Json.Serialization; namespace Bit.Core.Models.Slack; diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessor.cs b/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessor.cs index 59b5025eeb..da33289630 100644 --- a/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessor.cs +++ b/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessor.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using System.Reflection; using Bit.Core.Auth.Models.Data; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessor.cs b/src/Core/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessor.cs index 3ebcd1fb51..76d5c4b321 100644 --- a/src/Core/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessor.cs +++ b/src/Core/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessor.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models.Data; using Bit.Core.Enums; namespace Bit.Core.AdminConsole.OrganizationAuth.Models; diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdate.cs b/src/Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdate.cs index 5a4b4ed763..c829ed0ad6 100644 --- a/src/Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdate.cs +++ b/src/Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdate.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.AdminConsole.OrganizationAuth.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.AdminConsole.OrganizationAuth.Models; public class OrganizationAuthRequestUpdate { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs index 11bf6d7f66..86a222439e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/CreateGroupCommand.cs @@ -1,15 +1,14 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Groups; @@ -18,21 +17,16 @@ public class CreateGroupCommand : ICreateGroupCommand private readonly IEventService _eventService; private readonly IGroupRepository _groupRepository; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IReferenceEventService _referenceEventService; - private readonly ICurrentContext _currentContext; public CreateGroupCommand( IEventService eventService, IGroupRepository groupRepository, - IOrganizationUserRepository organizationUserRepository, - IReferenceEventService referenceEventService, - ICurrentContext currentContext) + IOrganizationUserRepository organizationUserRepository + ) { _eventService = eventService; _groupRepository = groupRepository; _organizationUserRepository = organizationUserRepository; - _referenceEventService = referenceEventService; - _currentContext = currentContext; } public async Task CreateGroupAsync(Group group, Organization organization, @@ -77,8 +71,6 @@ public class CreateGroupCommand : ICreateGroupCommand { await _groupRepository.CreateAsync(group, collections); } - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.GroupCreated, organization, _currentContext)); } private async Task GroupRepositoryUpdateUsersAsync(Group group, IEnumerable userIds, diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/ICreateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/ICreateGroupCommand.cs index b3ad06d9dc..13a5a00f43 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/ICreateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/ICreateGroupCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/IUpdateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/IUpdateGroupCommand.cs index 1cae4805f2..4ef95ceeae 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/IUpdateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/Interfaces/IUpdateGroupCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommand.cs index 1b53716537..3347e77c37 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Groups/UpdateGroupCommand.cs @@ -163,6 +163,11 @@ public class UpdateGroupCommand : IUpdateGroupCommand // Use generic error message to avoid enumeration throw new NotFoundException(); } + + if (collections.Any(c => c.Type == CollectionType.DefaultUserCollection)) + { + throw new BadRequestException("You cannot modify group access for collections with the type as DefaultUserCollection."); + } } private async Task ValidateMemberAccessAsync(Group originalGroup, diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs new file mode 100644 index 0000000000..b74da0a2e8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/IImportOrganizationUserAndGroupsCommand.cs @@ -0,0 +1,16 @@ +#nullable enable + +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.Models.Business; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IImportOrganizationUsersAndGroupsCommand +{ + Task ImportAsync(Guid organizationId, + IEnumerable groups, + IEnumerable newUsers, + IEnumerable removeUserExternalIds, + bool overwriteExisting + ); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs new file mode 100644 index 0000000000..87c6ddea6f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs @@ -0,0 +1,392 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Import; + +public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersAndGroupsCommand +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IPaymentService _paymentService; + private readonly IGroupRepository _groupRepository; + private readonly IEventService _eventService; + private readonly IOrganizationService _organizationService; + private readonly IFeatureService _featureService; + + private readonly EventSystemUser _EventSystemUser = EventSystemUser.PublicApi; + + public ImportOrganizationUsersAndGroupsCommand(IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPaymentService paymentService, + IGroupRepository groupRepository, + IEventService eventService, + IOrganizationService organizationService, + IFeatureService featureService) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _paymentService = paymentService; + _groupRepository = groupRepository; + _eventService = eventService; + _organizationService = organizationService; + _featureService = featureService; + } + + /// + /// Imports and synchronizes organization users and groups. + /// + /// The unique identifier of the organization. + /// List of groups to import. + /// List of users to import. + /// A collection of ExternalUserIds to be removed from the organization. + /// Indicates whether to delete existing external users from the organization + /// who are not included in the current import. + /// Thrown if the organization does not exist. + /// Thrown if the organization is not configured to use directory syncing. + public async Task ImportAsync(Guid organizationId, + IEnumerable importedGroups, + IEnumerable importedUsers, + IEnumerable removeUserExternalIds, + bool overwriteExisting) + { + var organization = await GetOrgById(organizationId); + if (organization is null) + { + throw new NotFoundException(); + } + + if (!organization.UseDirectory) + { + throw new BadRequestException("Organization cannot use directory syncing."); + } + + var existingUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + var importUserData = new OrganizationUserImportData(existingUsers, importedUsers); + var events = new List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)>(); + + await RemoveExistingExternalUsers(removeUserExternalIds, events, importUserData); + + if (overwriteExisting) + { + await OverwriteExisting(events, importUserData); + } + + await UpdateExistingUsers(importedUsers, importUserData); + + await AddNewUsers(organization, importedUsers, importUserData); + + await ImportGroups(organization, importedGroups, importUserData); + + await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.ou, e.e, _EventSystemUser, e.d))); + } + + /// + /// Deletes external users based on provided set of ExternalIds. + /// + /// A collection of external user IDs to be deleted. + /// A list to which user removal events will be added. + /// Data containing imported and existing external users. + + private async Task RemoveExistingExternalUsers(IEnumerable removeUserExternalIds, + List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)> events, + OrganizationUserImportData importUserData) + { + if (!removeUserExternalIds.Any()) + { + return; + } + + var existingUsersDict = importUserData.ExistingExternalUsers.ToDictionary(u => u.ExternalId); + // Determine which ids in removeUserExternalIds to delete based on: + // They are not in ImportedExternalIds, they are in existingUsersDict, and they are not an owner. + var removeUsersSet = new HashSet(removeUserExternalIds) + .Except(importUserData.ImportedExternalIds) + .Where(u => existingUsersDict.ContainsKey(u) && existingUsersDict[u].Type != OrganizationUserType.Owner) + .Select(u => existingUsersDict[u]); + + await _organizationUserRepository.DeleteManyAsync(removeUsersSet.Select(u => u.Id)); + events.AddRange(removeUsersSet.Select(u => ( + u, + EventType.OrganizationUser_Removed, + (DateTime?)DateTime.UtcNow + )) + ); + } + + /// + /// Updates existing organization users by assigning each an ExternalId from the imported user data + /// where a match is found by email and the existing user lacks an ExternalId. Saves the updated + /// users and updates the ExistingExternalUsersIdDict mapping. + /// + /// List of imported organization users. + /// Data containing existing and imported users, along with mapping dictionaries. + private async Task UpdateExistingUsers(IEnumerable importedUsers, OrganizationUserImportData importUserData) + { + if (!importedUsers.Any()) + { + return; + } + + var updateUsers = new List(); + + // Map existing and imported users to dicts keyed by Email + var existingUsersEmailsDict = importUserData.ExistingUsers + .Where(u => string.IsNullOrWhiteSpace(u.ExternalId)) + .ToDictionary(u => u.Email); + var importedUsersEmailsDict = importedUsers.ToDictionary(u => u.Email); + + // Determine which users to update. + var userEmailsToUpdate = existingUsersEmailsDict.Keys.Intersect(importedUsersEmailsDict.Keys).ToList(); + var userIdsToUpdate = userEmailsToUpdate.Select(e => existingUsersEmailsDict[e].Id).ToList(); + + var organizationUsers = (await _organizationUserRepository.GetManyAsync(userIdsToUpdate)).ToDictionary(u => u.Id); + + foreach (var userEmail in userEmailsToUpdate) + { + // verify userEmail has an associated OrganizationUser + existingUsersEmailsDict.TryGetValue(userEmail, out var existingUser); + organizationUsers.TryGetValue(existingUser!.Id, out var organizationUser); + importedUsersEmailsDict.TryGetValue(userEmail, out var importedUser); + + if (organizationUser is null || importedUser is null) + { + continue; + } + + organizationUser.ExternalId = importedUser.ExternalId; + updateUsers.Add(organizationUser); + importUserData.ExistingExternalUsersIdDict.Add(organizationUser.ExternalId, organizationUser.Id); + } + await _organizationUserRepository.UpsertManyAsync(updateUsers); + } + + /// + /// Adds new external users to the organization by inviting users who are present in the imported data + /// but not already part of the organization. Sends invitations, updates the user Id mapping on success, + /// and throws exceptions on failure. + /// + /// The target organization to which users are being added. + /// A collection of imported users to consider for addition. + /// Data containing imported user info and existing user mappings. + private async Task AddNewUsers(Organization organization, + IEnumerable importedUsers, + OrganizationUserImportData importUserData) + { + // Determine which users are already in the organization + var existingUsersSet = new HashSet(importUserData.ExistingExternalUsersIdDict.Keys); + var usersToAdd = importUserData.ImportedExternalIds.Except(existingUsersSet).ToList(); + var userInvites = new List<(OrganizationUserInvite, string)>(); + var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization); + + foreach (var user in importedUsers) + { + if (!usersToAdd.Contains(user.ExternalId) || string.IsNullOrWhiteSpace(user.Email)) + { + continue; + } + + try + { + var invite = new OrganizationUserInvite + { + Emails = new List { user.Email }, + Type = OrganizationUserType.User, + Collections = new List(), + AccessSecretsManager = hasStandaloneSecretsManager + }; + userInvites.Add((invite, user.ExternalId)); + } + catch (BadRequestException) + { + // Thrown when the user is already invited to the organization + continue; + } + } + + var invitedUsers = await _organizationService.InviteUsersAsync(organization.Id, Guid.Empty, _EventSystemUser, userInvites); + foreach (var invitedUser in invitedUsers) + { + importUserData.ExistingExternalUsersIdDict.TryAdd(invitedUser.ExternalId!, invitedUser.Id); + } + } + + /// + /// Deletes existing external users from the organization who are not included in the current import and are not owners. + /// Records corresponding removal events and updates the internal mapping by removing deleted users. + /// + /// A list to which user removal events will be added. + /// Data containing existing and imported external users along with their Id mappings. + private async Task OverwriteExisting( + List<(OrganizationUserUserDetails ou, EventType e, DateTime? d)> events, + OrganizationUserImportData importUserData) + { + var usersToDelete = importUserData.ExistingExternalUsers + .Where(u => + u.Type != OrganizationUserType.Owner && + !importUserData.ImportedExternalIds.Contains(u.ExternalId) && + importUserData.ExistingExternalUsersIdDict.ContainsKey(u.ExternalId)) + .ToList(); + + if (_featureService.IsEnabled(FeatureFlagKeys.DirectoryConnectorPreventUserRemoval) && + usersToDelete.Any(u => !u.HasMasterPassword)) + { + // Removing users without an MP will put their account in an unrecoverable state. + // We allow this during normal syncs for offboarding, but overwriteExisting risks bricking every user in + // the organization, so you don't get to do it here. + throw new BadRequestException( + "Sync failed. To proceed, disable the 'Remove and re-add users during next sync' setting and try again."); + } + + await _organizationUserRepository.DeleteManyAsync(usersToDelete.Select(u => u.Id)); + events.AddRange(usersToDelete.Select(u => ( + u, + EventType.OrganizationUser_Removed, + (DateTime?)DateTime.UtcNow + )) + ); + foreach (var deletedUser in usersToDelete) + { + importUserData.ExistingExternalUsersIdDict.Remove(deletedUser.ExternalId); + } + } + + /// + /// Imports group data into the organization by saving new groups and updating existing ones. + /// + /// The organization into which groups are being imported. + /// A collection of groups to be imported. + /// Data containing information about existing and imported users. + private async Task ImportGroups(Organization organization, IEnumerable importedGroups, OrganizationUserImportData importUserData) + { + if (!importedGroups.Any()) + { + return; + } + + if (!organization.UseGroups) + { + throw new BadRequestException("Organization cannot use groups."); + } + + var existingGroups = await _groupRepository.GetManyByOrganizationIdAsync(organization.Id); + var importGroupData = new OrganizationGroupImportData(importedGroups, existingGroups); + + await SaveNewGroups(importGroupData, importUserData); + await UpdateExistingGroups(importGroupData, importUserData, organization); + } + + /// + /// Saves newly imported groups that do not already exist in the organization. + /// Sets their creation and revision dates, associates users with each group. + /// + /// Data containing both imported and existing groups. + /// Data containing information about existing and imported users. + private async Task SaveNewGroups(OrganizationGroupImportData importGroupData, OrganizationUserImportData importUserData) + { + var existingExternalGroupsDict = importGroupData.ExistingExternalGroups.ToDictionary(g => g.ExternalId!); + var newGroups = importGroupData.Groups + .Where(g => !existingExternalGroupsDict.ContainsKey(g.Group.ExternalId!)) + .Select(g => g.Group) + .ToList()!; + + var savedGroups = new List(); + foreach (var group in newGroups) + { + group.CreationDate = group.RevisionDate = DateTime.UtcNow; + + savedGroups.Add(await _groupRepository.CreateAsync(group)); + await UpdateUsersAsync(group, importGroupData.GroupsDict[group.ExternalId!].ExternalUserIds, + importUserData.ExistingExternalUsersIdDict); + } + + await _eventService.LogGroupEventsAsync( + savedGroups.Select(g => (g, EventType.Group_Created, (EventSystemUser?)_EventSystemUser, (DateTime?)DateTime.UtcNow))); + } + + /// + /// Updates existing groups in the organization based on imported group data. + /// If a group's name has changed, it updates the name and revision date in the repository. + /// Also updates group-user associations. + /// + /// Data containing imported groups and their user associations. + /// Data containing imported and existing organization users. + /// The organization to which the groups belong. + private async Task UpdateExistingGroups(OrganizationGroupImportData importGroupData, + OrganizationUserImportData importUserData, + Organization organization) + { + var updateGroups = importGroupData.ExistingExternalGroups + .Where(g => importGroupData.GroupsDict.ContainsKey(g.ExternalId!)) + .ToList(); + + if (updateGroups.Any()) + { + // get existing group users + var groupUsers = await _groupRepository.GetManyGroupUsersByOrganizationIdAsync(organization.Id); + var existingGroupUsers = groupUsers + .GroupBy(gu => gu.GroupId) + .ToDictionary(g => g.Key, g => new HashSet(g.Select(gr => gr.OrganizationUserId))); + + foreach (var group in updateGroups) + { + // Check for changes to the group, update if changed. + var updatedGroup = importGroupData.GroupsDict[group.ExternalId!].Group; + if (group.Name != updatedGroup.Name) + { + group.RevisionDate = DateTime.UtcNow; + group.Name = updatedGroup.Name; + + await _groupRepository.ReplaceAsync(group); + } + + // compare and update user group associations + await UpdateUsersAsync(group, importGroupData.GroupsDict[group.ExternalId!].ExternalUserIds, + importUserData.ExistingExternalUsersIdDict, + existingGroupUsers.ContainsKey(group.Id) ? existingGroupUsers[group.Id] : null); + + } + + await _eventService.LogGroupEventsAsync( + updateGroups.Select(g => (g, EventType.Group_Updated, (EventSystemUser?)_EventSystemUser, (DateTime?)DateTime.UtcNow))); + } + + } + + /// + /// Updates the user associations for a given group. + /// Only updates if the set of associated users differs from the current group membership. + /// Filters users based on those present in the existing user Id dictionary. + /// + /// The group whose user associations are being updated. + /// A set of ExternalUserIds to be associated with the group. + /// A dictionary mapping ExternalUserIds to internal user Ids. + /// Optional set of currently associated user Ids for comparison. + private async Task UpdateUsersAsync(Group group, HashSet groupUsers, + Dictionary existingUsersIdDict, HashSet? existingUsers = null) + { + var availableUsers = groupUsers.Intersect(existingUsersIdDict.Keys); + var users = new HashSet(availableUsers.Select(u => existingUsersIdDict[u])); + if (existingUsers is not null && existingUsers.Count == users.Count && users.SetEquals(existingUsers)) + { + return; + } + + await _groupRepository.UpdateUsersAsync(group.Id, users); + } + + private async Task GetOrgById(Guid id) + { + return await _organizationRepository.GetByIdAsync(id); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationGroupImportData.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationGroupImportData.cs new file mode 100644 index 0000000000..6f49cb82e6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationGroupImportData.cs @@ -0,0 +1,41 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; + +namespace Bit.Core.Models.Data.Organizations; + +/// +/// Represents the data required to import organization groups, +/// including newly imported groups and existing groups within the organization. +/// +public class OrganizationGroupImportData +{ + /// + /// The collection of groups that are being imported. + /// + public readonly IEnumerable Groups; + + /// + /// Collection of groups that already exist in the organization. + /// + public readonly ICollection ExistingGroups; + + /// + /// Existing groups with ExternalId set. + /// + public readonly IEnumerable ExistingExternalGroups; + + /// + /// Mapping of imported groups keyed by their ExternalId. + /// + public readonly IDictionary GroupsDict; + + public OrganizationGroupImportData(IEnumerable groups, ICollection existingGroups) + { + Groups = groups; + GroupsDict = groups.ToDictionary(g => g.Group.ExternalId!); + ExistingGroups = existingGroups; + ExistingExternalGroups = existingGroups.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationUserImportData.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationUserImportData.cs new file mode 100644 index 0000000000..6575afe842 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/OrganizationUserImportData.cs @@ -0,0 +1,32 @@ +#nullable enable + +using Bit.Core.Models.Business; +namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; + +public class OrganizationUserImportData +{ + /// + /// Set of user ExternalIds that are being imported + /// + public readonly HashSet ImportedExternalIds; + /// + /// All existing OrganizationUsers for the organization + /// + public readonly ICollection ExistingUsers; + /// + /// Existing OrganizationUsers with ExternalIds set. + /// + public readonly IEnumerable ExistingExternalUsers; + /// + /// Mapping of an existing users's ExternalId to their Id + /// + public readonly Dictionary ExistingExternalUsersIdDict; + + public OrganizationUserImportData(ICollection existingUsers, IEnumerable importedUsers) + { + ImportedExternalIds = new HashSet(importedUsers?.Select(u => u.ExternalId) ?? new List()); + ExistingUsers = existingUsers; + ExistingExternalUsers = ExistingUsers.Where(u => !string.IsNullOrWhiteSpace(u.ExternalId)).ToList(); + ExistingExternalUsersIdDict = ExistingExternalUsers.ToDictionary(u => u.ExternalId, u => u.Id); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationApiKeys/GetOrganizationApiKeyQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationApiKeys/GetOrganizationApiKeyQuery.cs index b91e57a67c..f4a3b96372 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationApiKeys/GetOrganizationApiKeyQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationApiKeys/GetOrganizationApiKeyQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationConnections/ValidateBillingSyncKeyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationConnections/ValidateBillingSyncKeyCommand.cs index 3e19c773ef..ccc56297df 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationConnections/ValidateBillingSyncKeyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationConnections/ValidateBillingSyncKeyCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/GetOrganizationDomainByIdOrganizationIdQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/GetOrganizationDomainByIdOrganizationIdQuery.cs index 12616a142a..5f9c102208 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/GetOrganizationDomainByIdOrganizationIdQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/GetOrganizationDomainByIdOrganizationIdQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.Entities; using Bit.Core.Repositories; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index 43a3120ffd..c03341bbc0 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index f3426efddc..63f177b3f3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -1,4 +1,9 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -27,6 +32,8 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand private readonly IUserRepository _userRepository; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; + private readonly IFeatureService _featureService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public AcceptOrgUserCommand( IDataProtectionProvider dataProtectionProvider, @@ -37,9 +44,10 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand IMailService mailService, IUserRepository userRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IDataProtectorTokenFactory orgUserInviteTokenDataFactory) + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery) { - // TODO: remove data protector when old token validation removed _dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose); _globalSettings = globalSettings; @@ -50,6 +58,8 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand _userRepository = userRepository; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; + _featureService = featureService; + _policyRequirementQuery = policyRequirementQuery; } public async Task AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken, @@ -196,15 +206,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand } // Enforce Two Factor Authentication Policy of organization user is trying to join - if (!await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) - { - var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, - PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited); - if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) - { - throw new BadRequestException("You cannot join this organization until you enable two-step login on your user account."); - } - } + await ValidateTwoFactorAuthenticationPolicyAsync(user, orgUser.OrganizationId); orgUser.Status = OrganizationUserStatusType.Accepted; orgUser.UserId = user.Id; @@ -224,4 +226,33 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand return orgUser; } + private async Task ValidateTwoFactorAuthenticationPolicyAsync(User user, Guid organizationId) + { + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + if (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) + { + // If the user has two-step login enabled, we skip checking the 2FA policy + return; + } + + var twoFactorPolicyRequirement = await _policyRequirementQuery.GetAsync(user.Id); + if (twoFactorPolicyRequirement.IsTwoFactorRequiredForOrganization(organizationId)) + { + throw new BadRequestException("You cannot join this organization until you enable two-step login on your user account."); + } + + return; + } + + if (!await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user)) + { + var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, + PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited); + if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == organizationId)) + { + throw new BadRequestException("You cannot join this organization until you enable two-step login on your user account."); + } + } + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/OrganizationUserUserMiniDetailsAuthorizationHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/OrganizationUserUserMiniDetailsAuthorizationHandler.cs deleted file mode 100644 index 01b77a05b3..0000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Authorization/OrganizationUserUserMiniDetailsAuthorizationHandler.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; -using Bit.Core.Context; -using Microsoft.AspNetCore.Authorization; - -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; - -public class OrganizationUserUserMiniDetailsAuthorizationHandler : - AuthorizationHandler -{ - private readonly ICurrentContext _currentContext; - - public OrganizationUserUserMiniDetailsAuthorizationHandler(ICurrentContext currentContext) - { - _currentContext = currentContext; - } - - protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, - OrganizationUserUserMiniDetailsOperationRequirement requirement, OrganizationScope organizationScope) - { - var authorized = false; - - switch (requirement) - { - case not null when requirement.Name == nameof(OrganizationUserUserMiniDetailsOperations.ReadAll): - authorized = await CanReadAllAsync(organizationScope); - break; - } - - if (authorized) - { - context.Succeed(requirement); - } - } - - private async Task CanReadAllAsync(Guid organizationId) - { - // All organization users can access this data to manage collection access - var organization = _currentContext.GetOrganization(organizationId); - if (organization != null) - { - return true; - } - - // Providers can also access this to manage the organization generally - return await _currentContext.ProviderUserForOrgAsync(organizationId); - } -} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 9bfe8f791e..cbedb6355d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -1,5 +1,10 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; @@ -24,6 +29,9 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand private readonly IPushRegistrationService _pushRegistrationService; private readonly IPolicyService _policyService; private readonly IDeviceRepository _deviceRepository; + private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly IFeatureService _featureService; + private readonly ICollectionRepository _collectionRepository; public ConfirmOrganizationUserCommand( IOrganizationRepository organizationRepository, @@ -35,7 +43,10 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand IPushNotificationService pushNotificationService, IPushRegistrationService pushRegistrationService, IPolicyService policyService, - IDeviceRepository deviceRepository) + IDeviceRepository deviceRepository, + IPolicyRequirementQuery policyRequirementQuery, + IFeatureService featureService, + ICollectionRepository collectionRepository) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -47,12 +58,15 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand _pushRegistrationService = pushRegistrationService; _policyService = policyService; _deviceRepository = deviceRepository; + _policyRequirementQuery = policyRequirementQuery; + _featureService = featureService; + _collectionRepository = collectionRepository; } public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, - Guid confirmingUserId) + Guid confirmingUserId, string defaultUserCollectionName = null) { - var result = await ConfirmUsersAsync( + var result = await SaveChangesToDatabaseAsync( organizationId, new Dictionary() { { organizationUserId, key } }, confirmingUserId); @@ -67,10 +81,31 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand { throw new BadRequestException(error); } + + await HandleConfirmationSideEffectsAsync(organizationId, confirmedOrganizationUsers: [orgUser], defaultUserCollectionName); + return orgUser; } public async Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, + Guid confirmingUserId, string defaultUserCollectionName = null) + { + var result = await SaveChangesToDatabaseAsync(organizationId, keys, confirmingUserId); + + var confirmedOrganizationUsers = result + .Where(r => string.IsNullOrEmpty(r.Item2)) + .Select(r => r.Item1) + .ToList(); + + if (confirmedOrganizationUsers.Count > 0) + { + await HandleConfirmationSideEffectsAsync(organizationId, confirmedOrganizationUsers, defaultUserCollectionName); + } + + return result; + } + + private async Task>> SaveChangesToDatabaseAsync(Guid organizationId, Dictionary keys, Guid confirmingUserId) { var selectedOrganizationUsers = await _organizationUserRepository.GetManyAsync(keys.Keys); @@ -118,15 +153,14 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } } - var twoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled; - await CheckPoliciesAsync(organizationId, user, orgUsers, twoFactorEnabled); + var userTwoFactorEnabled = usersTwoFactorEnabled.FirstOrDefault(tuple => tuple.userId == user.Id).twoFactorIsEnabled; + await CheckPoliciesAsync(organizationId, user, orgUsers, userTwoFactorEnabled); orgUser.Status = OrganizationUserStatusType.Confirmed; orgUser.Key = keys[orgUser.Id]; orgUser.Email = null; await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager); - await DeleteAndPushUserRegistrationAsync(organizationId, user.Id); succeededUsers.Add(orgUser); result.Add(Tuple.Create(orgUser, "")); } @@ -137,20 +171,16 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } await _organizationUserRepository.ReplaceManyAsync(succeededUsers); + await DeleteAndPushUserRegistrationAsync(organizationId, succeededUsers.Select(u => u.UserId!.Value)); return result; } private async Task CheckPoliciesAsync(Guid organizationId, User user, - ICollection userOrgs, bool twoFactorEnabled) + ICollection userOrgs, bool userTwoFactorEnabled) { // Enforce Two Factor Authentication Policy for this organization - var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)) - .Any(p => p.OrganizationId == organizationId); - if (orgRequiresTwoFactor && !twoFactorEnabled) - { - throw new BadRequestException("User does not have two-step login enabled."); - } + await ValidateTwoFactorAuthenticationPolicyAsync(user, organizationId, userTwoFactorEnabled); var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId); var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); @@ -168,12 +198,42 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } } - private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, Guid userId) + private async Task ValidateTwoFactorAuthenticationPolicyAsync(User user, Guid organizationId, bool userTwoFactorEnabled) { - var devices = await GetUserDeviceIdsAsync(userId); - await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, - organizationId.ToString()); - await _pushNotificationService.PushSyncOrgKeysAsync(userId); + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + if (userTwoFactorEnabled) + { + // If the user has two-step login enabled, we skip checking the 2FA policy + return; + } + + var twoFactorPolicyRequirement = await _policyRequirementQuery.GetAsync(user.Id); + if (twoFactorPolicyRequirement.IsTwoFactorRequiredForOrganization(organizationId)) + { + throw new BadRequestException("User does not have two-step login enabled."); + } + + return; + } + + var orgRequiresTwoFactor = (await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication)) + .Any(p => p.OrganizationId == organizationId); + if (orgRequiresTwoFactor && !userTwoFactorEnabled) + { + throw new BadRequestException("User does not have two-step login enabled."); + } + } + + private async Task DeleteAndPushUserRegistrationAsync(Guid organizationId, IEnumerable userIds) + { + foreach (var userId in userIds) + { + var devices = await GetUserDeviceIdsAsync(userId); + await _pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, + organizationId.ToString()); + await _pushNotificationService.PushSyncOrgKeysAsync(userId); + } } private async Task> GetUserDeviceIdsAsync(Guid userId) @@ -183,4 +243,41 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) .Select(d => d.Id.ToString()); } + + /// + /// Handles the side effects of confirming an organization user. + /// Creates a default collection for the user if the organization + /// has the OrganizationDataOwnership policy enabled. + /// + /// The organization ID. + /// The confirmed organization users. + /// The encrypted default user collection name. + private async Task HandleConfirmationSideEffectsAsync(Guid organizationId, + IEnumerable confirmedOrganizationUsers, string defaultUserCollectionName) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) + { + return; + } + + // Skip if no collection name provided (backwards compatibility) + if (string.IsNullOrWhiteSpace(defaultUserCollectionName)) + { + return; + } + + var policyEligibleOrganizationUserIds = await _policyRequirementQuery.GetManyByOrganizationIdAsync(organizationId); + + var eligibleOrganizationUserIds = confirmedOrganizationUsers + .Where(ou => policyEligibleOrganizationUserIds.Contains(ou.Id)) + .Select(ou => ou.Id) + .ToList(); + + if (eligibleOrganizationUserIds.Count == 0) + { + return; + } + + await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs index 49ddf0a548..60a1c8bfbf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs @@ -7,9 +7,6 @@ using Bit.Core.Exceptions; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; #nullable enable @@ -24,7 +21,6 @@ public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganiz private readonly IUserRepository _userRepository; private readonly ICurrentContext _currentContext; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; - private readonly IReferenceEventService _referenceEventService; private readonly IPushNotificationService _pushService; private readonly IOrganizationRepository _organizationRepository; private readonly IProviderUserRepository _providerUserRepository; @@ -36,7 +32,6 @@ public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganiz IUserRepository userRepository, ICurrentContext currentContext, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IReferenceEventService referenceEventService, IPushNotificationService pushService, IOrganizationRepository organizationRepository, IProviderUserRepository providerUserRepository) @@ -48,7 +43,6 @@ public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganiz _userRepository = userRepository; _currentContext = currentContext; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; - _referenceEventService = referenceEventService; _pushService = pushService; _organizationRepository = organizationRepository; _providerUserRepository = providerUserRepository; @@ -195,8 +189,6 @@ public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganiz await _userRepository.DeleteManyAsync(users); foreach (var user in users) { - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.DeleteAccount, user, _currentContext)); await _pushService.PushLogOutAsync(user.Id); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs index e574d29e48..aca4853b66 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IConfirmOrganizationUserCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Exceptions; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; @@ -15,9 +18,10 @@ public interface IConfirmOrganizationUserCommand /// The ID of the organization user to confirm. /// The encrypted organization key for the user. /// The ID of the user performing the confirmation. + /// Optional encrypted collection name for creating a default collection. /// The confirmed organization user. /// Thrown when the user is not valid or cannot be confirmed. - Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId); + Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, string defaultUserCollectionName = null); /// /// Confirms multiple organization users who have accepted their invitations. @@ -25,7 +29,8 @@ public interface IConfirmOrganizationUserCommand /// The ID of the organization. /// A dictionary mapping organization user IDs to their encrypted organization keys. /// The ID of the user performing the confirmation. + /// Optional encrypted collection name for creating default collections. /// A list of tuples containing the organization user and an error message (if any). Task>> ConfirmUsersAsync(Guid organizationId, Dictionary keys, - Guid confirmingUserId); + Guid confirmingUserId, string defaultUserCollectionName = null); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..01ad2f05d2 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs @@ -0,0 +1,12 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IRevokeOrganizationUserCommand +{ + Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId); + Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); + Task>> RevokeUsersAsync(Guid organizationId, + IEnumerable organizationUserIds, Guid? revokingUserId); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IUpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IUpdateOrganizationUserCommand.cs index 0cd5a3295f..d1fc9fbbec 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IUpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IUpdateOrganizationUserCommand.cs @@ -1,11 +1,12 @@ #nullable enable using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Models.Data; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; public interface IUpdateOrganizationUserCommand { - Task UpdateUserAsync(OrganizationUser organizationUser, Guid? savingUserId, + Task UpdateUserAsync(OrganizationUser organizationUser, OrganizationUserType existingUserType, Guid? savingUserId, List? collectionAccess, IEnumerable? groupAccess); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs index 7e0a8dc3cd..7e8fd4c30a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IInviteOrganizationUsersCommand.cs @@ -19,4 +19,14 @@ public interface IInviteOrganizationUsersCommand /// /// Response from InviteScimOrganiation Task> InviteScimOrganizationUserAsync(InviteOrganizationUsersRequest request); + /// + /// Sends invitations to add imported organization users via the public API. + /// This can be a Success or a Failure. Failure will contain the Error along with a representation of the errored value. + /// Success will be the successful return object. + /// + /// + /// Contains the details for inviting the imported organization users. + /// + /// Response from InviteOrganiationUsersAsync + Task> InviteImportedOrganizationUsersAsync(InviteOrganizationUsersRequest request); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IResendOrganizationInviteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IResendOrganizationInviteCommand.cs new file mode 100644 index 0000000000..645cdb42d2 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IResendOrganizationInviteCommand.cs @@ -0,0 +1,14 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public interface IResendOrganizationInviteCommand +{ + /// + /// Resend an invite to an organization user. + /// + /// The ID of the organization. + /// The ID of the user who is inviting the organization user. + /// The ID of the organization user to resend the invite to. + /// Whether to initialize the organization. + /// This is should only be true when inviting the owner of a new organization. + Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs index 072bc5fc05..6899959b8d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Interfaces; using Bit.Core.AdminConsole.Models.Business; @@ -9,27 +12,20 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.AdminConsole.Utilities.Errors; using Bit.Core.AdminConsole.Utilities.Validation; -using Bit.Core.Context; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Microsoft.Extensions.Logging; -using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; public class InviteOrganizationUsersCommand(IEventService eventService, IOrganizationUserRepository organizationUserRepository, IInviteUsersValidator inviteUsersValidator, - IPaymentService paymentService, IOrganizationRepository organizationRepository, - IReferenceEventService referenceEventService, - ICurrentContext currentContext, IApplicationCacheService applicationCacheService, IMailService mailService, ILogger logger, @@ -77,6 +73,40 @@ public class InviteOrganizationUsersCommand(IEventService eventService, } } + public async Task> InviteImportedOrganizationUsersAsync(InviteOrganizationUsersRequest request) + { + var result = await InviteOrganizationUsersAsync(request); + + switch (result) + { + case Failure failure: + return new Failure( + new Error( + failure.Error.Message, + new InviteOrganizationUsersResponse(failure.Error.ErroredValue.InvitedUsers, request.InviteOrganization.OrganizationId) + ) + ); + + case Success success when success.Value.InvitedUsers.Any(): + + List<(OrganizationUser, EventType, EventSystemUser, DateTime?)> events = new List<(OrganizationUser, EventType, EventSystemUser, DateTime?)>(); + foreach (var user in success.Value.InvitedUsers) + { + events.Add((user, EventType.OrganizationUser_Invited, EventSystemUser.PublicApi, request.PerformedAt.UtcDateTime)); + } + + await eventService.LogOrganizationUserEventsAsync(events); + + return new Success(new InviteOrganizationUsersResponse(success.Value.InvitedUsers, request.InviteOrganization.OrganizationId) + ); + + default: + return new Failure( + new InvalidResultTypeError( + new InviteOrganizationUsersResponse(request.InviteOrganization.OrganizationId))); + } + } + private async Task> InviteOrganizationUsersAsync(InviteOrganizationUsersRequest request) { var invitesToSend = (await FilterExistingUsersAsync(request)).ToArray(); @@ -93,7 +123,7 @@ public class InviteOrganizationUsersCommand(IEventService eventService, InviteOrganization = request.InviteOrganization, PerformedBy = request.PerformedBy, PerformedAt = request.PerformedAt, - OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId), + OccupiedPmSeats = (await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)).Total, OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId) }); @@ -121,8 +151,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService, await SendAdditionalEmailsAsync(validatedRequest, organization); await SendInvitesAsync(organizationUserToInviteEntities, organization); - - await PublishReferenceEventAsync(validatedRequest, organization); } catch (Exception ex) { @@ -146,7 +174,7 @@ public class InviteOrganizationUsersCommand(IEventService eventService, organizationId: organization!.Id)); } - private async Task> FilterExistingUsersAsync(InviteOrganizationUsersRequest request) + private async Task> FilterExistingUsersAsync(InviteOrganizationUsersRequest request) { var existingEmails = new HashSet(await organizationUserRepository.SelectKnownEmailsAsync( request.InviteOrganization.OrganizationId, request.Invites.Select(i => i.Email), false), @@ -161,12 +189,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService, { if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { Seats: > 0, SeatsRequiredToAdd: > 0 }) { - - - await paymentService.AdjustSeatsAsync(organization, - validatedResult.Value.InviteOrganization.Plan, - validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats.Value); - organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats; await organizationRepository.ReplaceAsync(organization); @@ -190,14 +212,6 @@ public class InviteOrganizationUsersCommand(IEventService eventService, } } - private async Task PublishReferenceEventAsync(Valid validatedResult, - Organization organization) => - await referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, currentContext) - { - Users = validatedResult.Value.Invites.Length - }); - private async Task SendInvitesAsync(IEnumerable users, Organization organization) => await sendOrganizationInvitesCommand.SendInvitesAsync( new SendInvitesRequest( @@ -276,23 +290,15 @@ public class InviteOrganizationUsersCommand(IEventService eventService, { if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { SeatsRequiredToAdd: > 0, UpdatedSeatTotal: > 0 }) { - await paymentService.AdjustSeatsAsync(organization, - validatedResult.Value.InviteOrganization.Plan, - validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal.Value); + await organizationRepository.IncrementSeatCountAsync( + organization.Id, + validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd, + validatedResult.Value.PerformedAt.UtcDateTime); - organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal; + organization.Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal; + organization.SyncSeats = true; - await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update await applicationCacheService.UpsertOrganizationAbilityAsync(organization); - - await referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext) - { - PlanName = validatedResult.Value.InviteOrganization.Plan.Name, - PlanType = validatedResult.Value.InviteOrganization.Plan.Type, - Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal, - PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats - }); } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs index a55db3958a..bde56a66e8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUser.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Models.Data; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs index 23c38a51cb..b0f81bd92a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/CreateOrganizationUserExtensions.cs @@ -7,7 +7,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse public static class CreateOrganizationUserExtensions { - public static CreateOrganizationUser MapToDataModel(this OrganizationUserInvite organizationUserInvite, + public static CreateOrganizationUser MapToDataModel(this OrganizationUserInviteCommandModel organizationUserInvite, DateTimeOffset performedAt, InviteOrganization organization) => new() diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs index 84b350c551..2a54f26eb8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersRequest.cs @@ -4,12 +4,12 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse public class InviteOrganizationUsersRequest { - public OrganizationUserInvite[] Invites { get; } = []; + public OrganizationUserInviteCommandModel[] Invites { get; } = []; public InviteOrganization InviteOrganization { get; } public Guid PerformedBy { get; } public DateTimeOffset PerformedAt { get; } - public InviteOrganizationUsersRequest(OrganizationUserInvite[] invites, + public InviteOrganizationUsersRequest(OrganizationUserInviteCommandModel[] invites, InviteOrganization inviteOrganization, Guid performedBy, DateTimeOffset performedAt) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs index ac7d864dd4..5e461e7d0b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersResponse.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs index f45c705cab..e2eb91454c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/InviteOrganizationUsersValidationRequest.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.PasswordManager; using Bit.Core.Models.Business; @@ -29,7 +32,7 @@ public class InviteOrganizationUsersValidationRequest SecretsManagerSubscriptionUpdate = smSubscriptionUpdate; } - public OrganizationUserInvite[] Invites { get; init; } = []; + public OrganizationUserInviteCommandModel[] Invites { get; init; } = []; public InviteOrganization InviteOrganization { get; init; } public Guid PerformedBy { get; init; } public DateTimeOffset PerformedAt { get; init; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteCommandModel.cs similarity index 88% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteCommandModel.cs index 0b83680aa5..4d0f56efe4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInvite.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Models/OrganizationUserInviteCommandModel.cs @@ -7,7 +7,7 @@ using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Invite namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; -public class OrganizationUserInvite +public class OrganizationUserInviteCommandModel { public string Email { get; private init; } public CollectionAccessSelection[] AssignedCollections { get; private init; } @@ -17,7 +17,7 @@ public class OrganizationUserInvite public bool AccessSecretsManager { get; private init; } public Guid[] Groups { get; private init; } - public OrganizationUserInvite(string email, string externalId) : + public OrganizationUserInviteCommandModel(string email, string externalId) : this( email: email, assignedCollections: [], @@ -29,7 +29,7 @@ public class OrganizationUserInvite { } - public OrganizationUserInvite(OrganizationUserInvite invite, bool accessSecretsManager) : + public OrganizationUserInviteCommandModel(OrganizationUserInviteCommandModel invite, bool accessSecretsManager) : this(invite.Email, invite.AssignedCollections, invite.Groups, @@ -41,7 +41,7 @@ public class OrganizationUserInvite } - public OrganizationUserInvite(string email, + public OrganizationUserInviteCommandModel(string email, IEnumerable assignedCollections, IEnumerable groups, OrganizationUserType type, diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ResendOrganizationInviteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ResendOrganizationInviteCommand.cs new file mode 100644 index 0000000000..7e68af7816 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/ResendOrganizationInviteCommand.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Utilities.DebuggingInstruments; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public class ResendOrganizationInviteCommand : IResendOrganizationInviteCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly ILogger _logger; + + public ResendOrganizationInviteCommand( + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + ILogger logger) + { + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; + _logger = logger; + } + + public async Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, + bool initOrganization = false) + { + var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); + if (organizationUser == null || organizationUser.OrganizationId != organizationId || + organizationUser.Status != OrganizationUserStatusType.Invited) + { + throw new BadRequestException("User invalid."); + } + + _logger.LogUserInviteStateDiagnostics(organizationUser); + + var organization = await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId); + if (organization == null) + { + throw new BadRequestException("Organization invalid."); + } + await SendInviteAsync(organizationUser, organization, initOrganization); + } + + private async Task SendInviteAsync(OrganizationUser organizationUser, Organization organization, bool initOrganization) => + await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( + users: [organizationUser], + organization: organization, + initOrganization: initOrganization)); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs index ba85ce1d8a..cd5066d11b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.Repositories; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs index 54f26cb46a..f8bd988cab 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/InviteOrganizationUserValidator.cs @@ -6,7 +6,6 @@ using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; -using OrganizationUserInvite = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; @@ -38,7 +37,7 @@ public class InviteOrganizationUsersValidator( request = new InviteOrganizationUsersValidationRequest(request) { Invites = request.Invites - .Select(x => new OrganizationUserInvite(x, accessSecretsManager: true)) + .Select(x => new OrganizationUserInviteCommandModel(x, accessSecretsManager: true)) .ToArray() }; } @@ -60,9 +59,12 @@ public class InviteOrganizationUsersValidator( { try { + var organization = await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId); + + organization!.Seats = subscriptionUpdate.UpdatedSeatTotal; var smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate( - organization: await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId), + organization: organization, plan: request.InviteOrganization.Plan, autoscaling: true); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs index a1536ad439..67155fe91a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/PasswordManager/InviteUsersPasswordManagerValidator.cs @@ -1,7 +1,11 @@ -using Bit.Core.AdminConsole.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.Validation; @@ -83,14 +87,9 @@ public class InviteUsersPasswordManagerValidator( return invalidEnvironment.Map(request); } - var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization); - - if (organizationValidationResult is Invalid organizationValidation) - { - return organizationValidation.Map(request); - } - + // Organizations managed by a provider need to be scaled by the provider. This needs to be checked in the event seats are increasing. var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId); + if (provider is not null) { var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider)); @@ -101,6 +100,13 @@ public class InviteUsersPasswordManagerValidator( } } + var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization); + + if (organizationValidationResult is Invalid organizationValidation) + { + return organizationValidation.Map(request); + } + var paymentSubscription = await paymentService.GetSubscriptionAsync( await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId)); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs index 496dddc916..6de219f1cf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/InviteUserPaymentValidation.cs @@ -1,10 +1,9 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; using Bit.Core.AdminConsole.Utilities.Validation; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments; public static class InviteUserPaymentValidation { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs index dea35c4ddd..fcde0f9ebf 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/Validation/Payments/PaymentsSubscription.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Enums; using Bit.Core.Models.Business; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs index 587e04826b..b6152060e8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserUserDetailsQuery.cs @@ -3,7 +3,6 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Utilities; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; @@ -14,19 +13,16 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer { private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; - private readonly IFeatureService _featureService; private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; public OrganizationUserUserDetailsQuery( IOrganizationUserRepository organizationUserRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IFeatureService featureService, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery ) { _organizationUserRepository = organizationUserRepository; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; - _featureService = featureService; _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; } @@ -43,9 +39,12 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer return organizationUsers .Select(o => { - var userPermissions = o.GetPermissions(); - - o.Permissions = CoreHelpers.ClassToJsonData(userPermissions); + // Only set permissions for Custom user types for performance optimization + if (o.Type == OrganizationUserType.Custom) + { + var userPermissions = o.GetPermissions(); + o.Permissions = CoreHelpers.ClassToJsonData(userPermissions); + } return o; }); @@ -59,12 +58,30 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer /// List of OrganizationUserUserDetails public async Task> Get(OrganizationUserUserDetailsQueryRequest request) { - var organizationUsers = await GetOrganizationUserUserDetails(request); + var organizationUsers = await _organizationUserRepository + .GetManyDetailsByOrganizationAsync_vNext(request.OrganizationId, request.IncludeGroups, request.IncludeCollections); - var organizationUsersTwoFactorEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers)).ToDictionary(u => u.user.Id); - var organizationUsersClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id)); - var responses = organizationUsers.Select(o => (o, organizationUsersTwoFactorEnabled[o.Id].twoFactorIsEnabled, organizationUsersClaimedStatus[o.Id])); + var twoFactorTask = _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); + var claimedStatusTask = _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id)); + await Task.WhenAll(twoFactorTask, claimedStatusTask); + + var organizationUsersTwoFactorEnabled = twoFactorTask.Result.ToDictionary(u => u.user.Id, u => u.twoFactorIsEnabled); + var organizationUsersClaimedStatus = claimedStatusTask.Result; + var responses = organizationUsers.Select(organizationUserDetails => + { + // Only set permissions for Custom user types for performance optimization + if (organizationUserDetails.Type == OrganizationUserType.Custom) + { + var organizationUserPermissions = organizationUserDetails.GetPermissions(); + organizationUserDetails.Permissions = CoreHelpers.ClassToJsonData(organizationUserPermissions); + } + + var userHasTwoFactorEnabled = organizationUsersTwoFactorEnabled[organizationUserDetails.Id]; + var userIsClaimedByOrganization = organizationUsersClaimedStatus[organizationUserDetails.Id]; + + return (organizationUserDetails, userHasTwoFactorEnabled, userIsClaimedByOrganization); + }); return responses; } @@ -77,15 +94,33 @@ public class OrganizationUserUserDetailsQuery : IOrganizationUserUserDetailsQuer /// List of OrganizationUserUserDetails public async Task> GetAccountRecoveryEnrolledUsers(OrganizationUserUserDetailsQueryRequest request) { - var organizationUsers = (await GetOrganizationUserUserDetails(request)) - .Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && !String.IsNullOrEmpty(o.ResetPasswordKey)); + var organizationUsers = (await _organizationUserRepository + .GetManyDetailsByOrganizationAsync_vNext(request.OrganizationId, request.IncludeGroups, request.IncludeCollections)) + .Where(o => o.Status.Equals(OrganizationUserStatusType.Confirmed) && o.UsesKeyConnector == false && !String.IsNullOrEmpty(o.ResetPasswordKey)) + .ToArray(); - var organizationUsersTwoFactorEnabled = (await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers)).ToDictionary(u => u.user.Id); - var organizationUsersClaimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id)); - var responses = organizationUsers - .Select(o => (o, organizationUsersTwoFactorEnabled[o.Id].twoFactorIsEnabled, organizationUsersClaimedStatus[o.Id])); + var twoFactorTask = _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); + var claimedStatusTask = _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(request.OrganizationId, organizationUsers.Select(o => o.Id)); + + await Task.WhenAll(twoFactorTask, claimedStatusTask); + + var organizationUsersTwoFactorEnabled = twoFactorTask.Result.ToDictionary(u => u.user.Id, u => u.twoFactorIsEnabled); + var organizationUsersClaimedStatus = claimedStatusTask.Result; + var responses = organizationUsers.Select(organizationUserDetails => + { + // Only set permissions for Custom user types for performance optimization + if (organizationUserDetails.Type == OrganizationUserType.Custom) + { + var organizationUserPermissions = organizationUserDetails.GetPermissions(); + organizationUserDetails.Permissions = CoreHelpers.ClassToJsonData(organizationUserPermissions); + } + + var userHasTwoFactorEnabled = organizationUsersTwoFactorEnabled[organizationUserDetails.Id]; + var userIsClaimedByOrganization = organizationUsersClaimedStatus[organizationUserDetails.Id]; + + return (organizationUserDetails, userHasTwoFactorEnabled, userIsClaimedByOrganization); + }); return responses; } - } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs index 00d3ebb533..d1eec1bc76 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 74165a5a71..651a9225b4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -1,5 +1,10 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; @@ -22,7 +27,9 @@ public class RestoreOrganizationUserCommand( ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IPolicyService policyService, IUserRepository userRepository, - IOrganizationService organizationService) : IRestoreOrganizationUserCommand + IOrganizationService organizationService, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery) : IRestoreOrganizationUserCommand { public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId) { @@ -66,8 +73,8 @@ public class RestoreOrganizationUserCommand( } var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId); - var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats; + var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total; if (availableSeats < 1) { @@ -159,8 +166,8 @@ public class RestoreOrganizationUserCommand( } var organization = await organizationRepository.GetByIdAsync(organizationId); - var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats; + var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total; var newSeatsRequired = organizationUserIds.Count() - availableSeats; await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired); @@ -270,12 +277,7 @@ public class RestoreOrganizationUserCommand( // Enforce 2FA Policy of organization user is trying to join if (!userHasTwoFactorEnabled) { - var invitedTwoFactorPolicies = await policyService.GetPoliciesApplicableToUserAsync(userId, - PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked); - if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) - { - twoFactorCompliant = false; - } + twoFactorCompliant = !await IsTwoFactorRequiredForOrganizationAsync(userId, orgUser.OrganizationId); } var user = await userRepository.GetByIdAsync(userId); @@ -299,4 +301,17 @@ public class RestoreOrganizationUserCommand( throw new BadRequestException(user.Email + " is not compliant with the two-step login policy"); } } + + private async Task IsTwoFactorRequiredForOrganizationAsync(Guid userId, Guid organizationId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + var requirement = await policyRequirementQuery.GetAsync(userId); + return requirement.IsTwoFactorRequiredForOrganization(organizationId); + } + + var invitedTwoFactorPolicies = await policyService.GetPoliciesApplicableToUserAsync(userId, + PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked); + return invitedTwoFactorPolicies.Any(p => p.OrganizationId == organizationId); + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..f24e0ae265 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs @@ -0,0 +1,135 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public class RevokeOrganizationUserCommand( + IEventService eventService, + IPushNotificationService pushNotificationService, + IOrganizationUserRepository organizationUserRepository, + ICurrentContext currentContext, + IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) + : IRevokeOrganizationUserCommand +{ + public async Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId) + { + if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId.Value) + { + throw new BadRequestException("You cannot revoke yourself."); + } + + if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue && + !await currentContext.OrganizationOwner(organizationUser.OrganizationId)) + { + throw new BadRequestException("Only owners can revoke other owners."); + } + + await RepositoryRevokeUserAsync(organizationUser); + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); + + if (organizationUser.UserId.HasValue) + { + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); + } + } + + public async Task RevokeUserAsync(OrganizationUser organizationUser, + EventSystemUser systemUser) + { + await RepositoryRevokeUserAsync(organizationUser); + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, + systemUser); + + if (organizationUser.UserId.HasValue) + { + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); + } + } + + private async Task RepositoryRevokeUserAsync(OrganizationUser organizationUser) + { + if (organizationUser.Status == OrganizationUserStatusType.Revoked) + { + throw new BadRequestException("Already revoked."); + } + + if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationUser.OrganizationId, + new[] { organizationUser.Id }, includeProvider: true)) + { + throw new BadRequestException("Organization must have at least one confirmed owner."); + } + + await organizationUserRepository.RevokeAsync(organizationUser.Id); + organizationUser.Status = OrganizationUserStatusType.Revoked; + } + + public async Task>> RevokeUsersAsync(Guid organizationId, + IEnumerable organizationUserIds, Guid? revokingUserId) + { + var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds); + var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) + .ToList(); + + if (!filteredUsers.Any()) + { + throw new BadRequestException("Users invalid."); + } + + if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds)) + { + throw new BadRequestException("Organization must have at least one confirmed owner."); + } + + var deletingUserIsOwner = false; + if (revokingUserId.HasValue) + { + deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId); + } + + var result = new List>(); + + foreach (var organizationUser in filteredUsers) + { + try + { + if (organizationUser.Status == OrganizationUserStatusType.Revoked) + { + throw new BadRequestException("Already revoked."); + } + + if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId) + { + throw new BadRequestException("You cannot revoke yourself."); + } + + if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue && + !deletingUserIsOwner) + { + throw new BadRequestException("Only owners can revoke other owners."); + } + + await organizationUserRepository.RevokeAsync(organizationUser.Id); + organizationUser.Status = OrganizationUserStatusType.Revoked; + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); + if (organizationUser.UserId.HasValue) + { + await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); + } + + result.Add(Tuple.Create(organizationUser, "")); + } + catch (BadRequestException e) + { + result.Add(Tuple.Create(organizationUser, e.Message)); + } + } + + return result; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs index bad7b14b87..2623242ad6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/UpdateOrganizationUserCommand.cs @@ -55,11 +55,13 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand /// Update an organization user. /// /// The modified organization user to save. + /// The current type (member role) of the user. /// The userId of the currently logged in user who is making the change. /// The user's updated collection access. If set to null, this removes all collection access. /// The user's updated group access. If set to null, groups are not updated. /// - public async Task UpdateUserAsync(OrganizationUser organizationUser, Guid? savingUserId, + public async Task UpdateUserAsync(OrganizationUser organizationUser, OrganizationUserType existingUserType, + Guid? savingUserId, List? collectionAccess, IEnumerable? groupAccess) { // Avoid multiple enumeration @@ -83,19 +85,11 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand throw new NotFoundException(); } - if (organizationUser.UserId.HasValue && organization.PlanType == PlanType.Free && organizationUser.Type is OrganizationUserType.Admin or OrganizationUserType.Owner) - { - // Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this. - var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(organizationUser.UserId.Value); - if (adminCount > 0) - { - throw new BadRequestException("User can only be an admin of one free organization."); - } - } + await EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(organizationUser, existingUserType, organization); if (collectionAccessList.Count != 0) { - await ValidateCollectionAccessAsync(originalOrganizationUser, collectionAccessList); + collectionAccessList = await ValidateAccessAndFilterDefaultUserCollectionsAsync(originalOrganizationUser, collectionAccessList); } if (groupAccess?.Any() == true) @@ -151,11 +145,53 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Updated); } - private async Task ValidateCollectionAccessAsync(OrganizationUser originalUser, - ICollection collectionAccess) + private async Task EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(OrganizationUser updatedOrgUser, OrganizationUserType existingUserType, Entities.Organization organization) + { + + if (organization.PlanType != PlanType.Free) + { + return; + } + if (!updatedOrgUser.UserId.HasValue) + { + return; + } + if (updatedOrgUser.Type is not (OrganizationUserType.Admin or OrganizationUserType.Owner)) + { + return; + } + + // Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this. + var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(updatedOrgUser.UserId!.Value); + + var isCurrentAdminOrOwner = existingUserType is OrganizationUserType.Admin or OrganizationUserType.Owner; + + if (isCurrentAdminOrOwner && adminCount <= 1) + { + return; + } + + if (!isCurrentAdminOrOwner && adminCount == 0) + { + return; + } + + throw new BadRequestException("User can only be an admin of one free organization."); + } + + private async Task> ValidateAccessAndFilterDefaultUserCollectionsAsync( + OrganizationUser originalUser, List collectionAccess) { var collections = await _collectionRepository .GetManyByManyIdsAsync(collectionAccess.Select(c => c.Id)); + + ValidateCollections(originalUser, collectionAccess, collections); + + return ExcludeDefaultUserCollections(collectionAccess, collections); + } + + private static void ValidateCollections(OrganizationUser originalUser, List collectionAccess, ICollection collections) + { var collectionIds = collections.Select(c => c.Id); var missingCollection = collectionAccess @@ -173,6 +209,12 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand } } + private static List ExcludeDefaultUserCollections( + List collectionAccess, ICollection collections) => + collectionAccess + .Where(cas => collections.Any(c => c.Id == cas.Id && c.Type != CollectionType.DefaultUserCollection)) + .ToList(); + private async Task ValidateGroupAccessAsync(OrganizationUser originalUser, ICollection groupAccess) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 7449628ed0..8d8ab8cdfc 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -1,11 +1,13 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -15,9 +17,6 @@ using Bit.Core.Models.StaticStore; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; @@ -36,8 +35,6 @@ public class CloudOrganizationSignUpCommand( IOrganizationBillingService organizationBillingService, IPaymentService paymentService, IPolicyService policyService, - IReferenceEventService referenceEventService, - ICurrentContext currentContext, IOrganizationRepository organizationRepository, IOrganizationApiKeyRepository organizationApiKeyRepository, IApplicationCacheService applicationCacheService, @@ -132,17 +129,6 @@ public class CloudOrganizationSignUpCommand( var ownerId = signup.IsFromProvider ? default : signup.Owner.Id; var returnValue = await SignUpAsync(organization, ownerId, signup.OwnerKey, signup.CollectionName, true); - await referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, organization, currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Seats = returnValue.Item1.Seats, - SignupInitiationPath = signup.InitiationPath, - Storage = returnValue.Item1.MaxStorageGb, - // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 - }); - return new SignUpOrganizationResponse(returnValue.organization, returnValue.organizationUser); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/GetOrganizationSubscriptionsToUpdateQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/GetOrganizationSubscriptionsToUpdateQuery.cs new file mode 100644 index 0000000000..faf435addd --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/GetOrganizationSubscriptionsToUpdateQuery.cs @@ -0,0 +1,24 @@ +using Bit.Core.AdminConsole.Models.Data.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Pricing; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public class GetOrganizationSubscriptionsToUpdateQuery(IOrganizationRepository organizationRepository, + IPricingClient pricingClient) : IGetOrganizationSubscriptionsToUpdateQuery +{ + public async Task> GetOrganizationSubscriptionsToUpdateAsync() + { + var organizationsToUpdateTask = organizationRepository.GetOrganizationsForSubscriptionSyncAsync(); + var plansTask = pricingClient.ListPlans(); + + await Task.WhenAll(organizationsToUpdateTask, plansTask); + + return organizationsToUpdateTask.Result.Select(o => new OrganizationSubscriptionUpdate + { + Organization = o, + Plan = plansTask.Result.FirstOrDefault(plan => plan.Type == o.PlanType) + }); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs index 3e060c66a5..6474914b48 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Entities; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IGetOrganizationSubscriptionsToUpdateQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IGetOrganizationSubscriptionsToUpdateQuery.cs new file mode 100644 index 0000000000..e45a3ba957 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IGetOrganizationSubscriptionsToUpdateQuery.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.Models.Data.Organizations; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface IGetOrganizationSubscriptionsToUpdateQuery +{ + /// + /// Retrieves a collection of organization subscriptions that need to be updated. This is based on if the + /// Organization.SyncSeats flag is true and Organization.Seats has a value. + /// + /// + /// A collection of instances, each representing an organization + /// subscription to be updated with their associated plan. + /// + Task> GetOrganizationSubscriptionsToUpdateAsync(); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/ISelfHostedOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/ISelfHostedOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..2686384a34 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/ISelfHostedOrganizationSignUpCommand.cs @@ -0,0 +1,15 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface ISelfHostedOrganizationSignUpCommand +{ + /// + /// Create a new organization on a self-hosted instance + /// + Task<(Organization organization, OrganizationUser? organizationUser)> SignUpAsync( + OrganizationLicense license, User owner, string ownerKey, + string? collectionName, string publicKey, string privateKey); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IUpdateOrganizationSubscriptionCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IUpdateOrganizationSubscriptionCommand.cs new file mode 100644 index 0000000000..c8f5a15d39 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IUpdateOrganizationSubscriptionCommand.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.Models.Data.Organizations; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface IUpdateOrganizationSubscriptionCommand +{ + /// + /// Attempts to update the subscription of all organizations that have had a subscription update. + /// + /// If successful, the Organization.SyncSeats flag will be set to false and Organization.RevisionDate will be set. + /// + /// In the event of a failure, it will log the failure and maybe be picked up in later runs. + /// + /// The collection of organization subscriptions to update. + Task UpdateOrganizationSubscriptionAsync(IEnumerable subscriptionsToUpdate); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs index 185d5c5ac0..6a81130402 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationDeleteCommand.cs @@ -2,38 +2,28 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; -using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; public class OrganizationDeleteCommand : IOrganizationDeleteCommand { private readonly IApplicationCacheService _applicationCacheService; - private readonly ICurrentContext _currentContext; private readonly IOrganizationRepository _organizationRepository; private readonly IPaymentService _paymentService; - private readonly IReferenceEventService _referenceEventService; private readonly ISsoConfigRepository _ssoConfigRepository; public OrganizationDeleteCommand( IApplicationCacheService applicationCacheService, - ICurrentContext currentContext, IOrganizationRepository organizationRepository, IPaymentService paymentService, - IReferenceEventService referenceEventService, ISsoConfigRepository ssoConfigRepository) { _applicationCacheService = applicationCacheService; - _currentContext = currentContext; _organizationRepository = organizationRepository; _paymentService = paymentService; - _referenceEventService = referenceEventService; _ssoConfigRepository = ssoConfigRepository; } @@ -48,8 +38,6 @@ public class OrganizationDeleteCommand : IOrganizationDeleteCommand var eop = !organization.ExpirationDate.HasValue || organization.ExpirationDate.Value >= DateTime.UtcNow; await _paymentService.CancelSubscriptionAsync(organization, eop); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.DeleteAccount, organization, _currentContext)); } catch (GatewayException) { } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs index aa85c7e2a4..3f26ca372c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/OrganizationUpdateKeysCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs index b8802ffd0c..27e70fbe2d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Entities; @@ -8,9 +11,6 @@ using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; @@ -37,7 +37,6 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati private readonly ICurrentContext _currentContext; private readonly IPricingClient _pricingClient; - private readonly IReferenceEventService _referenceEventService; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; private readonly IApplicationCacheService _applicationCacheService; @@ -46,7 +45,6 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati public ProviderClientOrganizationSignUpCommand( ICurrentContext currentContext, IPricingClient pricingClient, - IReferenceEventService referenceEventService, IOrganizationRepository organizationRepository, IOrganizationApiKeyRepository organizationApiKeyRepository, IApplicationCacheService applicationCacheService, @@ -54,7 +52,6 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati { _currentContext = currentContext; _pricingClient = pricingClient; - _referenceEventService = referenceEventService; _organizationRepository = organizationRepository; _organizationApiKeyRepository = organizationApiKeyRepository; _applicationCacheService = applicationCacheService; @@ -108,16 +105,6 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati var returnValue = await SignUpAsync(organization, signup.CollectionName); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Seats = returnValue.Organization.Seats, - SignupInitiationPath = signup.InitiationPath, - Storage = returnValue.Organization.MaxStorageGb, - }); - return returnValue; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..446d7339ca --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ResellerClientOrganizationSignUpCommand.cs @@ -0,0 +1,130 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public record ResellerClientOrganizationSignUpResponse( + Organization Organization, + OrganizationUser OwnerOrganizationUser); + +/// +/// Command for signing up reseller client organizations in a pending state. +/// +public interface IResellerClientOrganizationSignUpCommand +{ + /// + /// Sign up a reseller client organization. The organization will be created in a pending state + /// (disabled and with Pending status) and the owner will be invited via email. The organization + /// will become active once the owner accepts the invitation. + /// + /// The organization to create. + /// The email of the organization owner who will be invited. + /// A response containing the created pending organization and invited owner user. + Task SignUpResellerClientAsync( + Organization organization, + string ownerEmail); +} + +public class ResellerClientOrganizationSignUpCommand : IResellerClientOrganizationSignUpCommand +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; + private readonly IApplicationCacheService _applicationCacheService; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IEventService _eventService; + private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly IPaymentService _paymentService; + + public ResellerClientOrganizationSignUpCommand( + IOrganizationRepository organizationRepository, + IOrganizationApiKeyRepository organizationApiKeyRepository, + IApplicationCacheService applicationCacheService, + IOrganizationUserRepository organizationUserRepository, + IEventService eventService, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + IPaymentService paymentService) + { + _organizationRepository = organizationRepository; + _organizationApiKeyRepository = organizationApiKeyRepository; + _applicationCacheService = applicationCacheService; + _organizationUserRepository = organizationUserRepository; + _eventService = eventService; + _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; + _paymentService = paymentService; + } + + public async Task SignUpResellerClientAsync( + Organization organization, + string ownerEmail) + { + try + { + var createdOrganization = await CreateOrganizationAsync(organization); + var ownerOrganizationUser = await CreateAndInviteOwnerAsync(createdOrganization, ownerEmail); + + await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited); + + return new ResellerClientOrganizationSignUpResponse(organization, ownerOrganizationUser); + } + catch + { + await _paymentService.CancelAndRecoverChargesAsync(organization); + + if (organization.Id != default) + { + // Deletes the organization and all related data, including its owner user + await _organizationRepository.DeleteAsync(organization); + await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); + } + + throw; + } + } + + private async Task CreateOrganizationAsync(Organization organization) + { + organization.Id = CoreHelpers.GenerateComb(); + organization.Enabled = false; + organization.Status = OrganizationStatusType.Pending; + + await _organizationRepository.CreateAsync(organization); + await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey + { + OrganizationId = organization.Id, + ApiKey = CoreHelpers.SecureRandomString(30), + Type = OrganizationApiKeyType.Default, + RevisionDate = DateTime.UtcNow, + }); + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + return organization; + } + + private async Task CreateAndInviteOwnerAsync(Organization organization, string ownerEmail) + { + var ownerOrganizationUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = null, + Email = ownerEmail, + Key = null, + Type = OrganizationUserType.Owner, + Status = OrganizationUserStatusType.Invited, + }; + + await _organizationUserRepository.CreateAsync(ownerOrganizationUser); + + await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest( + users: [ownerOrganizationUser], + organization: organization, + initOrganization: true)); + + return ownerOrganizationUser; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..c52b7c10c9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs @@ -0,0 +1,216 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUpCommand +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; + private readonly IApplicationCacheService _applicationCacheService; + private readonly ICollectionRepository _collectionRepository; + private readonly IPushRegistrationService _pushRegistrationService; + private readonly IPushNotificationService _pushNotificationService; + private readonly IDeviceRepository _deviceRepository; + private readonly ILicensingService _licensingService; + private readonly IPolicyService _policyService; + private readonly IGlobalSettings _globalSettings; + private readonly IPaymentService _paymentService; + + public SelfHostedOrganizationSignUpCommand( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationApiKeyRepository organizationApiKeyRepository, + IApplicationCacheService applicationCacheService, + ICollectionRepository collectionRepository, + IPushRegistrationService pushRegistrationService, + IPushNotificationService pushNotificationService, + IDeviceRepository deviceRepository, + ILicensingService licensingService, + IPolicyService policyService, + IGlobalSettings globalSettings, + IPaymentService paymentService) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + _organizationApiKeyRepository = organizationApiKeyRepository; + _applicationCacheService = applicationCacheService; + _collectionRepository = collectionRepository; + _pushRegistrationService = pushRegistrationService; + _pushNotificationService = pushNotificationService; + _deviceRepository = deviceRepository; + _licensingService = licensingService; + _policyService = policyService; + _globalSettings = globalSettings; + _paymentService = paymentService; + } + + public async Task<(Organization organization, OrganizationUser? organizationUser)> SignUpAsync( + OrganizationLicense license, User owner, string ownerKey, string? collectionName, string publicKey, + string privateKey) + { + if (license.LicenseType != LicenseType.Organization) + { + throw new BadRequestException("Premium licenses cannot be applied to an organization. " + + "Upload this license from your personal account settings page."); + } + + var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); + var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception); + + if (!canUse) + { + throw new BadRequestException(exception); + } + + var enabledOrgs = await _organizationRepository.GetManyByEnabledAsync(); + if (enabledOrgs.Any(o => string.Equals(o.LicenseKey, license.LicenseKey))) + { + throw new BadRequestException("License is already in use by another organization."); + } + + await ValidateSignUpPoliciesAsync(owner.Id); + + var organization = claimsPrincipal != null + // If the ClaimsPrincipal exists (there's a token on the license), use it to build the organization. + ? OrganizationFactory.Create(owner, claimsPrincipal, publicKey, privateKey) + // If there's no ClaimsPrincipal (there's no token on the license), use the license to build the organization. + : OrganizationFactory.Create(owner, license, publicKey, privateKey); + + var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); + + var dir = $"{_globalSettings.LicenseDirectory}/organization"; + Directory.CreateDirectory(dir); + await using var fs = new FileStream(Path.Combine(dir, $"{organization.Id}.json"), FileMode.Create); + await JsonSerializer.SerializeAsync(fs, license, JsonHelpers.Indented); + return (result.organization, result.organizationUser); + } + + private async Task ValidateSignUpPoliciesAsync(Guid ownerId) + { + var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); + if (anySingleOrgPolicies) + { + throw new BadRequestException("You may not create an organization. You belong to an organization " + + "which has a policy that prohibits you from being a member of any other organization."); + } + } + + /// + /// Private helper method to create a new organization. + /// This is common code used by both the cloud and self-hosted methods. + /// + private async Task<(Organization organization, OrganizationUser? organizationUser, Collection? defaultCollection)> + SignUpAsync(Organization organization, + Guid ownerId, string ownerKey, string? collectionName, bool withPayment) + { + try + { + await _organizationRepository.CreateAsync(organization); + await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey + { + OrganizationId = organization.Id, + ApiKey = CoreHelpers.SecureRandomString(30), + Type = OrganizationApiKeyType.Default, + RevisionDate = DateTime.UtcNow, + }); + await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + // ownerId == default if the org is created by a provider - in this case it's created without an + // owner and the first owner is immediately invited afterwards + OrganizationUser? orgUser = null; + if (ownerId != default) + { + orgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = ownerId, + Key = ownerKey, + AccessSecretsManager = organization.UseSecretsManager, + Type = OrganizationUserType.Owner, + Status = OrganizationUserStatusType.Confirmed, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + orgUser.SetNewId(); + + await _organizationUserRepository.CreateAsync(orgUser); + + var devices = await GetUserDeviceIdsAsync(orgUser.UserId!.Value); + await _pushRegistrationService.AddUserRegistrationOrganizationAsync(devices, + organization.Id.ToString()); + await _pushNotificationService.PushSyncOrgKeysAsync(ownerId); + } + + Collection? defaultCollection = null; + if (!string.IsNullOrWhiteSpace(collectionName)) + { + defaultCollection = new Collection + { + Name = collectionName, + OrganizationId = organization.Id, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + + // Give the owner Can Manage access over the default collection + List? defaultOwnerAccess = null; + if (orgUser != null) + { + defaultOwnerAccess = + [ + new CollectionAccessSelection + { + Id = orgUser.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + } + ]; + } + + await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); + } + + return (organization, orgUser, defaultCollection); + } + catch + { + if (withPayment) + { + await _paymentService.CancelAndRecoverChargesAsync(organization); + } + + if (organization.Id != default(Guid)) + { + await _organizationRepository.DeleteAsync(organization); + await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); + } + + throw; + } + } + + private async Task> GetUserDeviceIdsAsync(Guid userId) + { + var devices = await _deviceRepository.GetManyByUserIdAsync(userId); + return devices + .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) + .Select(d => d.Id.ToString()); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs new file mode 100644 index 0000000000..450f425bdf --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs @@ -0,0 +1,43 @@ +using Bit.Core.AdminConsole.Models.Data.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public class UpdateOrganizationSubscriptionCommand(IPaymentService paymentService, + IOrganizationRepository repository, + TimeProvider timeProvider, + ILogger logger) : IUpdateOrganizationSubscriptionCommand +{ + public async Task UpdateOrganizationSubscriptionAsync(IEnumerable subscriptionsToUpdate) + { + var successfulSyncs = new List(); + + foreach (var subscriptionUpdate in subscriptionsToUpdate) + { + try + { + await paymentService.AdjustSeatsAsync(subscriptionUpdate.Organization, + subscriptionUpdate.Plan, + subscriptionUpdate.Seats); + + successfulSyncs.Add(subscriptionUpdate.Organization.Id); + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to update organization {organizationId} subscription.", + subscriptionUpdate.Organization.Id); + } + } + + if (successfulSyncs.Count == 0) + { + return; + } + + await repository.UpdateSuccessfulOrganizationSyncStatusAsync(successfulSyncs, timeProvider.GetUtcNow().UtcDateTime); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyRequirementQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyRequirementQuery.cs index 5736078f22..e662716142 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyRequirementQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyRequirementQuery.cs @@ -15,4 +15,13 @@ public interface IPolicyRequirementQuery /// The user that you need to enforce the policy against. /// The IPolicyRequirement that corresponds to the policy you want to enforce. Task GetAsync(Guid userId) where T : IPolicyRequirement; + + /// + /// Get all organization user IDs within an organization that are affected by a given policy type. + /// Respects role/status/provider exemptions via the policy factory's Enforce predicate. + /// + /// The organization to check. + /// The IPolicyRequirement that corresponds to the policy type to evaluate. + /// Organization user IDs for whom the policy applies within the organization. + Task> GetManyByOrganizationIdAsync(Guid organizationId) where T : IPolicyRequirement; } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs index de4796d4b5..e846e02e46 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs @@ -1,5 +1,6 @@ #nullable enable +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; @@ -27,6 +28,29 @@ public class PolicyRequirementQuery( return requirement; } + public async Task> GetManyByOrganizationIdAsync(Guid organizationId) + where T : IPolicyRequirement + { + var factory = factories.OfType>().SingleOrDefault(); + if (factory is null) + { + throw new NotImplementedException("No Requirement Factory found for " + typeof(T)); + } + + var organizationPolicyDetails = await GetOrganizationPolicyDetails(organizationId, factory.PolicyType); + + var eligibleOrganizationUserIds = organizationPolicyDetails + .Where(p => p.PolicyType == factory.PolicyType) + .Where(factory.Enforce) + .Select(p => p.OrganizationUserId) + .ToList(); + + return eligibleOrganizationUserIds; + } + private Task> GetPolicyDetails(Guid userId) => policyRepository.GetPolicyDetailsByUserId(userId); + + private async Task> GetOrganizationPolicyDetails(Guid organizationId, PolicyType policyType) + => await policyRepository.GetPolicyDetailsByOrganizationIdAsync(organizationId, policyType); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs index cf332e689a..71212aaf4c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -104,8 +104,8 @@ public class SavePolicyCommand : ISavePolicyCommand var dependentPolicyTypes = _policyValidators.Values .Where(otherValidator => otherValidator.RequiredPolicies.Contains(policyUpdate.Type)) .Select(otherValidator => otherValidator.Type) - .Where(otherPolicyType => savedPoliciesDict.ContainsKey(otherPolicyType) && - savedPoliciesDict[otherPolicyType].Enabled) + .Where(otherPolicyType => savedPoliciesDict.TryGetValue(otherPolicyType, out var savedPolicy) && + savedPolicy.Enabled) .ToList(); switch (dependentPolicyTypes) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/MasterPasswordPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/MasterPasswordPolicyRequirement.cs new file mode 100644 index 0000000000..9e8154db53 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/MasterPasswordPolicyRequirement.cs @@ -0,0 +1,55 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +/// +/// Policy requirements for the Master Password Requirements policy. +/// +public class MasterPasswordPolicyRequirement : IPolicyRequirement +{ + /// + /// Indicates whether MasterPassword requirements are enabled for the user. + /// + public bool Enabled { get; init; } + + /// + /// Master Password Policy data model associated with this Policy + /// + public MasterPasswordPolicyData? EnforcedOptions { get; init; } +} + +public class MasterPasswordPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.MasterPassword; + + protected override bool ExemptProviders => false; + + protected override IEnumerable ExemptRoles => []; + + protected override IEnumerable ExemptStatuses => + [OrganizationUserStatusType.Accepted, + OrganizationUserStatusType.Invited, + OrganizationUserStatusType.Revoked, + ]; + + public override MasterPasswordPolicyRequirement Create(IEnumerable policyDetails) + { + var result = policyDetails + .Select(p => p.GetDataModel()) + .Aggregate( + new MasterPasswordPolicyRequirement(), + (result, data) => + { + data.CombineWith(result.EnforcedOptions); + return new MasterPasswordPolicyRequirement + { + Enabled = true, + EnforcedOptions = data + }; + }); + + return result; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs new file mode 100644 index 0000000000..7ccb3f7807 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs @@ -0,0 +1,72 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +/// +/// Represents the Organization Data Ownership policy state. +/// +public enum OrganizationDataOwnershipState +{ + /// + /// Organization Data Ownership is enforced- members are required to save items to an organization. + /// + Enabled = 1, + + /// + /// Organization Data Ownership is not enforced- users can save items to their personal vault. + /// + Disabled = 2 +} + +/// +/// Policy requirements for the Organization data ownership policy +/// +public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement +{ + private readonly IEnumerable _organizationIdsWithPolicyEnabled; + + /// + /// The organization data ownership state for the user. + /// + /// + /// The collection of Organization IDs that have the Organization Data Ownership policy enabled. + /// + public OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState organizationDataOwnershipState, + IEnumerable organizationIdsWithPolicyEnabled) + { + _organizationIdsWithPolicyEnabled = organizationIdsWithPolicyEnabled ?? []; + State = organizationDataOwnershipState; + } + + /// + /// The Organization data ownership policy state for the user. + /// + public OrganizationDataOwnershipState State { get; } + + /// + /// Returns true if the Organization Data Ownership policy is enforced in that organization. + /// + public bool RequiresDefaultCollection(Guid organizationId) + { + return _organizationIdsWithPolicyEnabled.Contains(organizationId); + } +} + +public class OrganizationDataOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.OrganizationDataOwnership; + + public override OrganizationDataOwnershipPolicyRequirement Create(IEnumerable policyDetails) + { + var organizationDataOwnershipState = policyDetails.Any() + ? OrganizationDataOwnershipState.Enabled + : OrganizationDataOwnershipState.Disabled; + var organizationIdsWithPolicyEnabled = policyDetails.Select(p => p.OrganizationId).ToHashSet(); + + return new OrganizationDataOwnershipPolicyRequirement( + organizationDataOwnershipState, + organizationIdsWithPolicyEnabled); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirement.cs deleted file mode 100644 index 6f3f017bb9..0000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/PersonalOwnershipPolicyRequirement.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; - -namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; - -/// -/// Policy requirements for the Disable Personal Ownership policy. -/// -public class PersonalOwnershipPolicyRequirement : IPolicyRequirement -{ - /// - /// Indicates whether Personal Ownership is disabled for the user. If true, members are required to save items to an organization. - /// - public bool DisablePersonalOwnership { get; init; } -} - -public class PersonalOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory -{ - public override PolicyType PolicyType => PolicyType.PersonalOwnership; - - public override PersonalOwnershipPolicyRequirement Create(IEnumerable policyDetails) - { - var result = new PersonalOwnershipPolicyRequirement { DisablePersonalOwnership = policyDetails.Any() }; - return result; - } -} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/RequireTwoFactorPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/RequireTwoFactorPolicyRequirement.cs new file mode 100644 index 0000000000..bbc997a83d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/RequireTwoFactorPolicyRequirement.cs @@ -0,0 +1,52 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +/// +/// Policy requirements for the Require Two-Factor Authentication policy. +/// +public class RequireTwoFactorPolicyRequirement : IPolicyRequirement +{ + private readonly IEnumerable _policyDetails; + + public RequireTwoFactorPolicyRequirement(IEnumerable policyDetails) + { + _policyDetails = policyDetails; + } + + /// + /// Checks if two-factor authentication is required for the organization due to an active policy. + /// + /// The ID of the organization to check. + /// True if two-factor authentication is required for the organization, false otherwise. + /// + /// This should be used to check whether the member needs to have 2FA enabled before being + /// accepted, confirmed, or restored to the organization. + /// + public bool IsTwoFactorRequiredForOrganization(Guid organizationId) => + _policyDetails.Any(p => p.OrganizationId == organizationId); + + /// + /// Returns tuples of (OrganizationId, OrganizationUserId) for active memberships where two-factor authentication is required. + /// Users should be revoked from these organizations if they disable all 2FA methods. + /// + public IEnumerable<(Guid OrganizationId, Guid OrganizationUserId)> OrganizationsRequiringTwoFactor => + _policyDetails + .Where(p => p.OrganizationUserStatus is + OrganizationUserStatusType.Accepted or + OrganizationUserStatusType.Confirmed) + .Select(p => (p.OrganizationId, p.OrganizationUserId)); +} + +public class RequireTwoFactorPolicyRequirementFactory : BasePolicyRequirementFactory +{ + public override PolicyType PolicyType => PolicyType.TwoFactorAuthentication; + protected override IEnumerable ExemptStatuses => []; + + public override RequireTwoFactorPolicyRequirement Create(IEnumerable policyDetails) + { + return new RequireTwoFactorPolicyRequirement(policyDetails); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs index b7d0b14f15..1d703fa4d4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/ResetPasswordPolicyRequirement.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 1be0e61af7..e31e9d44c9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -34,7 +34,9 @@ public static class PolicyServiceCollectionExtensions services.AddScoped, DisableSendPolicyRequirementFactory>(); services.AddScoped, SendOptionsPolicyRequirementFactory>(); services.AddScoped, ResetPasswordPolicyRequirementFactory>(); - services.AddScoped, PersonalOwnershipPolicyRequirementFactory>(); + services.AddScoped, OrganizationDataOwnershipPolicyRequirementFactory>(); services.AddScoped, RequireSsoPolicyRequirementFactory>(); + services.AddScoped, RequireTwoFactorPolicyRequirementFactory>(); + services.AddScoped, MasterPasswordPolicyRequirementFactory>(); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs index 13cc935eb9..5ce72df6c1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs @@ -104,8 +104,8 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages)); } - await Task.WhenAll(currentActiveRevocableOrganizationUsers.Select(x => - _mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), x.Email))); + await Task.WhenAll(nonCompliantUsers.Select(nonCompliantUser => + _mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), nonCompliantUser.user.Email))); } private static bool MembersWithNoMasterPasswordWillLoseAccess( diff --git a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs index 516918fff9..0a774cf395 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs @@ -10,4 +10,8 @@ public interface IOrganizationIntegrationConfigurationRepository : IRepository> GetAllConfigurationDetailsAsync(); + + Task> GetManyByIntegrationAsync(Guid organizationIntegrationId); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs index cd7700c310..434c8ddee3 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs @@ -4,4 +4,5 @@ namespace Bit.Core.Repositories; public interface IOrganizationIntegrationRepository : IRepository { + Task> GetManyByOrganizationAsync(Guid organizationId); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index 7e315ed58b..da7a77000b 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; #nullable enable @@ -23,6 +24,42 @@ public interface IOrganizationRepository : IRepository /// Gets the organizations that have a verified domain matching the user's email domain. /// Task> GetByVerifiedUserEmailDomainAsync(Guid userId); + Task> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType); Task> GetManyByIdsAsync(IEnumerable ids); + + /// + /// Returns the number of occupied seats for an organization. + /// OrganizationUsers occupy a seat, unless they are revoked. + /// As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an + /// organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization. + /// + /// The ID of the organization to get the occupied seat count for. + /// The number of occupied seats for the organization. + Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId); + + /// + /// Get all organizations that need to have their seat count updated to their Stripe subscription. + /// + /// Organizations to sync to Stripe + Task> GetOrganizationsForSubscriptionSyncAsync(); + + /// + /// Updates the organization SeatSync property to signify the organization's subscription has been updated in stripe + /// to match the password manager seats for the organization. + /// + /// + /// + /// + Task UpdateSuccessfulOrganizationSyncStatusAsync(IEnumerable successfulOrganizations, DateTime syncDate); + + /// + /// This increments the password manager seat count on the organization by the provided amount and sets SyncSeats to true. + /// It also sets the revision date using the request date. + /// + /// Organization to update + /// Amount to increase password manager seats by + /// When the action was performed + /// + Task IncrementSeatCountAsync(Guid organizationId, int increaseAmount, DateTime requestDate); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index 9692de897c..37a830c92e 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -18,22 +18,30 @@ public interface IOrganizationUserRepository : IRepository> GetManyByUserAsync(Guid userId); Task> GetManyByOrganizationAsync(Guid organizationId, OrganizationUserType? type); Task GetCountByOrganizationAsync(Guid organizationId, string email, bool onlyRegisteredUsers); - - /// - /// Returns the number of occupied seats for an organization. - /// Occupied seats are OrganizationUsers that have at least been invited. - /// As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an - /// organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization. - /// - /// The ID of the organization to get the occupied seat count for. - /// The number of occupied seats for the organization. - Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId); Task> SelectKnownEmailsAsync(Guid organizationId, IEnumerable emails, bool onlyRegisteredUsers); Task GetByOrganizationAsync(Guid organizationId, Guid userId); Task>> GetByIdWithCollectionsAsync(Guid id); Task GetDetailsByIdAsync(Guid id); + /// + /// Returns the OrganizationUser and its associated collections (excluding DefaultUserCollections). + /// + /// The id of the OrganizationUser + /// A tuple containing the OrganizationUser and its associated collections Task<(OrganizationUserUserDetails? OrganizationUser, ICollection Collections)> GetDetailsByIdWithCollectionsAsync(Guid id); + /// + /// Returns the OrganizationUsers and their associated collections (excluding DefaultUserCollections). + /// + /// The id of the organization + /// Whether to include groups + /// Whether to include collections + /// A list of OrganizationUserUserDetails Task> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false); + /// + /// + /// This method is optimized for performance. + /// Reduces database round trips by fetching all data in fewer queries. + /// + Task> GetManyDetailsByOrganizationAsync_vNext(Guid organizationId, bool includeGroups = false, bool includeCollections = false); Task> GetManyDetailsByUserAsync(Guid userId, OrganizationUserStatusType? status = null); Task GetDetailsByUserAsync(Guid userId, Guid organizationId, @@ -68,7 +76,6 @@ public interface IOrganizationUserRepository : IRepository Task> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId); - Task RevokeManyByIdAsync(IEnumerable organizationUserIds); /// diff --git a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs index 4c0c03536d..2b46c040bb 100644 --- a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs +++ b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs @@ -31,4 +31,17 @@ public interface IPolicyRepository : IRepository /// You probably do not want to call it directly. /// Task> GetPolicyDetailsByUserId(Guid userId); + + /// + /// Retrieves of the specified + /// for users in the given organization and for any other organizations those users belong to. + /// + /// + /// Each PolicyDetail represents an OrganizationUser and a Policy which *may* be enforced + /// against them. It only returns PolicyDetails for policies that are enabled and where the organization's plan + /// supports policies. It also excludes "revoked invited" users who are not subject to policy enforcement. + /// This is consumed by to create requirements for specific policy types. + /// You probably do not want to call it directly. + /// + Task> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType); } diff --git a/src/Core/AdminConsole/Services/EventLoggingListenerService.cs b/src/Core/AdminConsole/Services/EventLoggingListenerService.cs index 60b8789a6b..53ff3d4d0a 100644 --- a/src/Core/AdminConsole/Services/EventLoggingListenerService.cs +++ b/src/Core/AdminConsole/Services/EventLoggingListenerService.cs @@ -1,13 +1,87 @@ -using Microsoft.Extensions.Hosting; +#nullable enable + +using System.Text.Json; +using Bit.Core.Models.Data; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Bit.Core.Services; public abstract class EventLoggingListenerService : BackgroundService { protected readonly IEventMessageHandler _handler; + protected ILogger _logger; - protected EventLoggingListenerService(IEventMessageHandler handler) + protected EventLoggingListenerService(IEventMessageHandler handler, ILogger logger) { - _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + _handler = handler; + _logger = logger; + } + + internal async Task ProcessReceivedMessageAsync(string body, string? messageId) + { + try + { + using var jsonDocument = JsonDocument.Parse(body); + var root = jsonDocument.RootElement; + + if (root.ValueKind == JsonValueKind.Array) + { + var eventMessages = root.Deserialize>(); + await _handler.HandleManyEventsAsync(eventMessages); + } + else if (root.ValueKind == JsonValueKind.Object) + { + var eventMessage = root.Deserialize(); + await _handler.HandleEventAsync(eventMessage); + } + else + { + if (!string.IsNullOrEmpty(messageId)) + { + _logger.LogError("An error occurred while processing message: {MessageId} - Invalid JSON", messageId); + } + else + { + _logger.LogError("An Invalid JSON error occurred while processing a message with an empty message id"); + } + } + } + catch (JsonException exception) + { + if (!string.IsNullOrEmpty(messageId)) + { + _logger.LogError( + exception, + "An error occurred while processing message: {MessageId} - Invalid JSON", + messageId + ); + } + else + { + _logger.LogError( + exception, + "An Invalid JSON error occurred while processing a message with an empty message id" + ); + } + } + catch (Exception exception) + { + if (!string.IsNullOrEmpty(messageId)) + { + _logger.LogError( + exception, + "An error occurred while processing message: {MessageId}", + messageId + ); + } + else + { + _logger.LogError( + exception, + "An error occurred while processing a message with an empty message id" + ); + } + } } } diff --git a/src/Core/AdminConsole/Services/IAzureServiceBusService.cs b/src/Core/AdminConsole/Services/IAzureServiceBusService.cs new file mode 100644 index 0000000000..75864255c2 --- /dev/null +++ b/src/Core/AdminConsole/Services/IAzureServiceBusService.cs @@ -0,0 +1,10 @@ +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +namespace Bit.Core.Services; + +public interface IAzureServiceBusService : IEventIntegrationPublisher, IAsyncDisposable +{ + ServiceBusProcessor CreateProcessor(string topicName, string subscriptionName, ServiceBusProcessorOptions options); + Task PublishToRetryAsync(IIntegrationMessage message); +} diff --git a/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs b/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs new file mode 100644 index 0000000000..b80b518223 --- /dev/null +++ b/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +namespace Bit.Core.Services; + +public interface IEventIntegrationPublisher : IAsyncDisposable +{ + Task PublishAsync(IIntegrationMessage message); + Task PublishEventAsync(string body); +} diff --git a/src/Core/AdminConsole/Services/IEventMessageHandler.cs b/src/Core/AdminConsole/Services/IEventMessageHandler.cs index 83c5e33ecb..fcffb56c65 100644 --- a/src/Core/AdminConsole/Services/IEventMessageHandler.cs +++ b/src/Core/AdminConsole/Services/IEventMessageHandler.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Data; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/IEventService.cs b/src/Core/AdminConsole/Services/IEventService.cs index 5b4f8731a2..ba6d4da8f5 100644 --- a/src/Core/AdminConsole/Services/IEventService.cs +++ b/src/Core/AdminConsole/Services/IEventService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Interfaces; using Bit.Core.Entities; @@ -30,6 +33,6 @@ public interface IEventService Task LogProviderOrganizationEventsAsync(IEnumerable<(ProviderOrganization, EventType, DateTime?)> events); Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, DateTime? date = null); Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, EventSystemUser systemUser, DateTime? date = null); - Task LogServiceAccountSecretEventAsync(Guid serviceAccountId, Secret secret, EventType type, DateTime? date = null); + Task LogUserSecretsEventAsync(Guid userId, IEnumerable secrets, EventType type, DateTime? date = null); Task LogServiceAccountSecretsEventAsync(Guid serviceAccountId, IEnumerable secrets, EventType type, DateTime? date = null); } diff --git a/src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs b/src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs new file mode 100644 index 0000000000..ad27429112 --- /dev/null +++ b/src/Core/AdminConsole/Services/IIntegrationConfigurationDetailsCache.cs @@ -0,0 +1,14 @@ +#nullable enable + +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.Services; + +public interface IIntegrationConfigurationDetailsCache +{ + List GetConfigurationDetails( + Guid organizationId, + IntegrationType integrationType, + EventType eventType); +} diff --git a/src/Core/AdminConsole/Services/IIntegrationFilterService.cs b/src/Core/AdminConsole/Services/IIntegrationFilterService.cs new file mode 100644 index 0000000000..5bc035d468 --- /dev/null +++ b/src/Core/AdminConsole/Services/IIntegrationFilterService.cs @@ -0,0 +1,11 @@ +#nullable enable + +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Models.Data; + +namespace Bit.Core.Services; + +public interface IIntegrationFilterService +{ + bool EvaluateFilterGroup(IntegrationFilterGroup group, EventMessage message); +} diff --git a/src/Core/AdminConsole/Services/IIntegrationHandler.cs b/src/Core/AdminConsole/Services/IIntegrationHandler.cs index bf6e6791cf..9a3edac9ec 100644 --- a/src/Core/AdminConsole/Services/IIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/IIntegrationHandler.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Models.Data.Integrations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/IIntegrationPublisher.cs b/src/Core/AdminConsole/Services/IIntegrationPublisher.cs deleted file mode 100644 index 986ea776e1..0000000000 --- a/src/Core/AdminConsole/Services/IIntegrationPublisher.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.AdminConsole.Models.Data.Integrations; - -namespace Bit.Core.Services; - -public interface IIntegrationPublisher -{ - Task PublishAsync(IIntegrationMessage message); -} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 5fe68bd22e..6adfc4772f 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -1,4 +1,6 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Auth.Enums; @@ -18,11 +20,6 @@ public interface IOrganizationService Task AutoAddSeatsAsync(Organization organization, int seatsToAdd); Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment); Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); - /// - /// Create a new organization on a self-hosted instance - /// - Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync(OrganizationLicense license, User owner, - string ownerKey, string collectionName, string publicKey, string privateKey); Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate); Task UpdateAsync(Organization organization, bool updateBilling = false, EventType eventType = EventType.Organization_Updated); Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); @@ -32,17 +29,11 @@ public interface IOrganizationService Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites); Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, IEnumerable organizationUsersId); - Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false); Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId); Task ImportAsync(Guid organizationId, IEnumerable groups, IEnumerable newUsers, IEnumerable removeUserExternalIds, bool overwriteExisting, EventSystemUser eventSystemUser); Task DeleteSsoUserAsync(Guid userId, Guid? organizationId); - Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId); - Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser); - Task>> RevokeUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? revokingUserId); - Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted); Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null); Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd); diff --git a/src/Core/AdminConsole/Services/IProviderService.cs b/src/Core/AdminConsole/Services/IProviderService.cs index e4b6f3aabd..66c49d90c6 100644 --- a/src/Core/AdminConsole/Services/IProviderService.cs +++ b/src/Core/AdminConsole/Services/IProviderService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.Billing.Models; using Bit.Core.Entities; diff --git a/src/Core/AdminConsole/Services/IRabbitMqService.cs b/src/Core/AdminConsole/Services/IRabbitMqService.cs new file mode 100644 index 0000000000..12c40c3b98 --- /dev/null +++ b/src/Core/AdminConsole/Services/IRabbitMqService.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; + +namespace Bit.Core.Services; + +public interface IRabbitMqService : IEventIntegrationPublisher +{ + Task CreateChannelAsync(CancellationToken cancellationToken = default); + Task CreateEventQueueAsync(string queueName, CancellationToken cancellationToken = default); + Task CreateIntegrationQueuesAsync( + string queueName, + string retryQueueName, + string routingKey, + CancellationToken cancellationToken = default); + Task PublishToRetryAsync(IChannel channel, IIntegrationMessage message, CancellationToken cancellationToken); + Task PublishToDeadLetterAsync(IChannel channel, IIntegrationMessage message, CancellationToken cancellationToken); + Task RepublishToRetryQueueAsync(IChannel channel, BasicDeliverEventArgs eventArgs); +} diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs deleted file mode 100644 index 4cd71ae77e..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventListenerService.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Text; -using System.Text.Json; -using Azure.Messaging.ServiceBus; -using Bit.Core.Models.Data; -using Bit.Core.Settings; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Services; - -public class AzureServiceBusEventListenerService : EventLoggingListenerService -{ - private readonly ILogger _logger; - private readonly ServiceBusClient _client; - private readonly ServiceBusProcessor _processor; - - public AzureServiceBusEventListenerService( - IEventMessageHandler handler, - ILogger logger, - GlobalSettings globalSettings, - string subscriptionName) : base(handler) - { - _client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString); - _processor = _client.CreateProcessor(globalSettings.EventLogging.AzureServiceBus.TopicName, subscriptionName, new ServiceBusProcessorOptions()); - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken cancellationToken) - { - _processor.ProcessMessageAsync += async args => - { - try - { - using var jsonDocument = JsonDocument.Parse(Encoding.UTF8.GetString(args.Message.Body)); - var root = jsonDocument.RootElement; - - if (root.ValueKind == JsonValueKind.Array) - { - var eventMessages = root.Deserialize>(); - await _handler.HandleManyEventsAsync(eventMessages); - } - else if (root.ValueKind == JsonValueKind.Object) - { - var eventMessage = root.Deserialize(); - await _handler.HandleEventAsync(eventMessage); - - } - await args.CompleteMessageAsync(args.Message); - } - catch (Exception exception) - { - _logger.LogError( - exception, - "An error occured while processing message: {MessageId}", - args.Message.MessageId - ); - } - }; - - _processor.ProcessErrorAsync += args => - { - _logger.LogError( - args.Exception, - "An error occurred. Entity Path: {EntityPath}, Error Source: {ErrorSource}", - args.EntityPath, - args.ErrorSource - ); - return Task.CompletedTask; - }; - - await _processor.StartProcessingAsync(cancellationToken); - } - - public override async Task StopAsync(CancellationToken cancellationToken) - { - await _processor.StopProcessingAsync(cancellationToken); - await base.StopAsync(cancellationToken); - } - - public override void Dispose() - { - _processor.DisposeAsync().GetAwaiter().GetResult(); - _client.DisposeAsync().GetAwaiter().GetResult(); - base.Dispose(); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventWriteService.cs deleted file mode 100644 index fc865b327c..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/AzureServiceBusEventWriteService.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Text.Json; -using Azure.Messaging.ServiceBus; -using Bit.Core.Models.Data; -using Bit.Core.Services; -using Bit.Core.Settings; - -namespace Bit.Core.AdminConsole.Services.Implementations; - -public class AzureServiceBusEventWriteService : IEventWriteService, IAsyncDisposable -{ - private readonly ServiceBusClient _client; - private readonly ServiceBusSender _sender; - - public AzureServiceBusEventWriteService(GlobalSettings globalSettings) - { - _client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString); - _sender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.TopicName); - } - - public async Task CreateAsync(IEvent e) - { - var message = new ServiceBusMessage(JsonSerializer.SerializeToUtf8Bytes(e)) - { - ContentType = "application/json" - }; - - await _sender.SendMessageAsync(message); - } - - public async Task CreateManyAsync(IEnumerable events) - { - var message = new ServiceBusMessage(JsonSerializer.SerializeToUtf8Bytes(events)) - { - ContentType = "application/json" - }; - - await _sender.SendMessageAsync(message); - } - - public async ValueTask DisposeAsync() - { - await _sender.DisposeAsync(); - await _client.DisposeAsync(); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/AzureTableStorageEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/AzureTableStorageEventHandler.cs index aa545913b1..578dde9485 100644 --- a/src/Core/AdminConsole/Services/Implementations/AzureTableStorageEventHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/AzureTableStorageEventHandler.cs @@ -1,4 +1,6 @@ -using Bit.Core.Models.Data; +#nullable enable + +using Bit.Core.Models.Data; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs deleted file mode 100644 index 9a80ed67b2..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrationHandler.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Text.Json; -using Bit.Core.AdminConsole.Models.Data.Integrations; -using Bit.Core.AdminConsole.Utilities; -using Bit.Core.Enums; -using Bit.Core.Models.Data; -using Bit.Core.Repositories; - -namespace Bit.Core.Services; - -#nullable enable - -public class EventIntegrationHandler( - IntegrationType integrationType, - IIntegrationPublisher integrationPublisher, - IOrganizationIntegrationConfigurationRepository configurationRepository, - IUserRepository userRepository, - IOrganizationRepository organizationRepository) - : IEventMessageHandler -{ - public async Task HandleEventAsync(EventMessage eventMessage) - { - if (eventMessage.OrganizationId is not Guid organizationId) - { - return; - } - - var configurations = await configurationRepository.GetConfigurationDetailsAsync( - organizationId, - integrationType, - eventMessage.Type); - - foreach (var configuration in configurations) - { - var template = configuration.Template ?? string.Empty; - var context = await BuildContextAsync(eventMessage, template); - var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context); - - var config = configuration.MergedConfiguration.Deserialize() - ?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name}"); - - var message = new IntegrationMessage - { - IntegrationType = integrationType, - Configuration = config, - RenderedTemplate = renderedTemplate, - RetryCount = 0, - DelayUntilDate = null - }; - - await integrationPublisher.PublishAsync(message); - } - } - - public async Task HandleManyEventsAsync(IEnumerable eventMessages) - { - foreach (var eventMessage in eventMessages) - { - await HandleEventAsync(eventMessage); - } - } - - private async Task BuildContextAsync(EventMessage eventMessage, string template) - { - var context = new IntegrationTemplateContext(eventMessage); - - if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue) - { - context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value); - } - - if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue) - { - context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value); - } - - if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue) - { - context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value); - } - - return context; - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs new file mode 100644 index 0000000000..f5eb41c051 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs @@ -0,0 +1,65 @@ +#nullable enable + +using System.Text; +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class AzureServiceBusEventListenerService : EventLoggingListenerService + where TConfiguration : IEventListenerConfiguration +{ + private readonly ServiceBusProcessor _processor; + + public AzureServiceBusEventListenerService( + TConfiguration configuration, + IEventMessageHandler handler, + IAzureServiceBusService serviceBusService, + ILoggerFactory loggerFactory) + : base(handler, CreateLogger(loggerFactory, configuration)) + { + _processor = serviceBusService.CreateProcessor( + topicName: configuration.EventTopicName, + subscriptionName: configuration.EventSubscriptionName, + new ServiceBusProcessorOptions()); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + _processor.ProcessMessageAsync += ProcessReceivedMessageAsync; + _processor.ProcessErrorAsync += ProcessErrorAsync; + + await _processor.StartProcessingAsync(cancellationToken); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await _processor.StopProcessingAsync(cancellationToken); + await _processor.DisposeAsync(); + await base.StopAsync(cancellationToken); + } + + private static ILogger CreateLogger(ILoggerFactory loggerFactory, TConfiguration configuration) + { + return loggerFactory.CreateLogger( + categoryName: $"Bit.Core.Services.AzureServiceBusEventListenerService.{configuration.EventSubscriptionName}"); + } + + internal Task ProcessErrorAsync(ProcessErrorEventArgs args) + { + _logger.LogError( + args.Exception, + "An error occurred. Entity Path: {EntityPath}, Error Source: {ErrorSource}", + args.EntityPath, + args.ErrorSource + ); + return Task.CompletedTask; + } + + private async Task ProcessReceivedMessageAsync(ProcessMessageEventArgs args) + { + await ProcessReceivedMessageAsync(Encoding.UTF8.GetString(args.Message.Body), args.Message.MessageId); + await args.CompleteMessageAsync(args.Message); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs new file mode 100644 index 0000000000..037ae7e647 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs @@ -0,0 +1,112 @@ +#nullable enable + +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class AzureServiceBusIntegrationListenerService : BackgroundService + where TConfiguration : IIntegrationListenerConfiguration +{ + private readonly int _maxRetries; + private readonly IAzureServiceBusService _serviceBusService; + private readonly IIntegrationHandler _handler; + private readonly ServiceBusProcessor _processor; + private readonly ILogger _logger; + + public AzureServiceBusIntegrationListenerService( + TConfiguration configuration, + IIntegrationHandler handler, + IAzureServiceBusService serviceBusService, + ILoggerFactory loggerFactory) + { + _handler = handler; + _logger = loggerFactory.CreateLogger( + categoryName: $"Bit.Core.Services.AzureServiceBusIntegrationListenerService.{configuration.IntegrationSubscriptionName}"); + _maxRetries = configuration.MaxRetries; + _serviceBusService = serviceBusService; + + _processor = _serviceBusService.CreateProcessor( + topicName: configuration.IntegrationTopicName, + subscriptionName: configuration.IntegrationSubscriptionName, + options: new ServiceBusProcessorOptions()); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + _processor.ProcessMessageAsync += HandleMessageAsync; + _processor.ProcessErrorAsync += ProcessErrorAsync; + + await _processor.StartProcessingAsync(cancellationToken); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await _processor.StopProcessingAsync(cancellationToken); + await _processor.DisposeAsync(); + await base.StopAsync(cancellationToken); + } + + internal Task ProcessErrorAsync(ProcessErrorEventArgs args) + { + _logger.LogError( + args.Exception, + "An error occurred. Entity Path: {EntityPath}, Error Source: {ErrorSource}", + args.EntityPath, + args.ErrorSource + ); + return Task.CompletedTask; + } + + internal async Task HandleMessageAsync(string body) + { + try + { + var result = await _handler.HandleAsync(body); + var message = result.Message; + + if (result.Success) + { + // Successful integration. Return true to indicate the message has been handled + return true; + } + + message.ApplyRetry(result.DelayUntilDate); + + if (result.Retryable && message.RetryCount < _maxRetries) + { + // Publish message to the retry queue. It will be re-published for retry after a delay + // Return true to indicate the message has been handled + await _serviceBusService.PublishToRetryAsync(message); + return true; + } + else + { + // Non-recoverable failure or exceeded the max number of retries + // Return false to indicate this message should be dead-lettered + return false; + } + } + catch (Exception ex) + { + // Unknown exception - log error, return true so the message will be acknowledged and not resent + _logger.LogError(ex, "Unhandled error processing ASB message"); + return true; + } + } + + private async Task HandleMessageAsync(ProcessMessageEventArgs args) + { + var json = args.Message.Body.ToString(); + if (await HandleMessageAsync(json)) + { + await args.CompleteMessageAsync(args.Message); + } + else + { + await args.DeadLetterMessageAsync(args.Message, "Retry limit exceeded or non-retryable"); + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs new file mode 100644 index 0000000000..4887aa3a7f --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs @@ -0,0 +1,70 @@ +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.Services; + +public class AzureServiceBusService : IAzureServiceBusService +{ + private readonly ServiceBusClient _client; + private readonly ServiceBusSender _eventSender; + private readonly ServiceBusSender _integrationSender; + + public AzureServiceBusService(GlobalSettings globalSettings) + { + _client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString); + _eventSender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.EventTopicName); + _integrationSender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName); + } + + public ServiceBusProcessor CreateProcessor(string topicName, string subscriptionName, ServiceBusProcessorOptions options) + { + return _client.CreateProcessor(topicName, subscriptionName, options); + } + + public async Task PublishAsync(IIntegrationMessage message) + { + var json = message.ToJson(); + + var serviceBusMessage = new ServiceBusMessage(json) + { + Subject = message.IntegrationType.ToRoutingKey(), + MessageId = message.MessageId + }; + + await _integrationSender.SendMessageAsync(serviceBusMessage); + } + + public async Task PublishToRetryAsync(IIntegrationMessage message) + { + var json = message.ToJson(); + + var serviceBusMessage = new ServiceBusMessage(json) + { + Subject = message.IntegrationType.ToRoutingKey(), + ScheduledEnqueueTime = message.DelayUntilDate ?? DateTime.UtcNow, + MessageId = message.MessageId + }; + + await _integrationSender.SendMessageAsync(serviceBusMessage); + } + + public async Task PublishEventAsync(string body) + { + var message = new ServiceBusMessage(body) + { + ContentType = "application/json", + MessageId = Guid.NewGuid().ToString() + }; + + await _eventSender.SendMessageAsync(message); + } + + public async ValueTask DisposeAsync() + { + await _eventSender.DisposeAsync(); + await _integrationSender.DisposeAsync(); + await _client.DisposeAsync(); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs new file mode 100644 index 0000000000..519f8aeb32 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs @@ -0,0 +1,32 @@ +#nullable enable + +using System.Text.Json; +using Bit.Core.Models.Data; + +namespace Bit.Core.Services; +public class EventIntegrationEventWriteService : IEventWriteService, IAsyncDisposable +{ + private readonly IEventIntegrationPublisher _eventIntegrationPublisher; + + public EventIntegrationEventWriteService(IEventIntegrationPublisher eventIntegrationPublisher) + { + _eventIntegrationPublisher = eventIntegrationPublisher; + } + + public async Task CreateAsync(IEvent e) + { + var body = JsonSerializer.Serialize(e); + await _eventIntegrationPublisher.PublishEventAsync(body: body); + } + + public async Task CreateManyAsync(IEnumerable events) + { + var body = JsonSerializer.Serialize(events); + await _eventIntegrationPublisher.PublishEventAsync(body: body); + } + + public async ValueTask DisposeAsync() + { + await _eventIntegrationPublisher.DisposeAsync(); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs new file mode 100644 index 0000000000..9cd789be76 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs @@ -0,0 +1,110 @@ +#nullable enable + +using System.Text.Json; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Utilities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class EventIntegrationHandler( + IntegrationType integrationType, + IEventIntegrationPublisher eventIntegrationPublisher, + IIntegrationFilterService integrationFilterService, + IIntegrationConfigurationDetailsCache configurationCache, + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + ILogger> logger) + : IEventMessageHandler +{ + public async Task HandleEventAsync(EventMessage eventMessage) + { + if (eventMessage.OrganizationId is not Guid organizationId) + { + return; + } + + var configurations = configurationCache.GetConfigurationDetails( + organizationId, + integrationType, + eventMessage.Type); + + foreach (var configuration in configurations) + { + try + { + if (configuration.Filters is string filterJson) + { + // Evaluate filters - if false, then discard and do not process + var filters = JsonSerializer.Deserialize(filterJson) + ?? throw new InvalidOperationException($"Failed to deserialize Filters to FilterGroup"); + if (!integrationFilterService.EvaluateFilterGroup(filters, eventMessage)) + { + continue; + } + } + + // Valid filter - assemble message and publish to Integration topic/exchange + var template = configuration.Template ?? string.Empty; + var context = await BuildContextAsync(eventMessage, template); + var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context); + var messageId = eventMessage.IdempotencyId ?? Guid.NewGuid(); + var config = configuration.MergedConfiguration.Deserialize() + ?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name} - bad Configuration"); + + var message = new IntegrationMessage + { + IntegrationType = integrationType, + MessageId = messageId.ToString(), + Configuration = config, + RenderedTemplate = renderedTemplate, + RetryCount = 0, + DelayUntilDate = null + }; + + await eventIntegrationPublisher.PublishAsync(message); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to publish Integration Message for {Type}, check Id {RecordId} for error in Configuration or Filters", + typeof(T).Name, + configuration.Id); + } + } + } + + public async Task HandleManyEventsAsync(IEnumerable eventMessages) + { + foreach (var eventMessage in eventMessages) + { + await HandleEventAsync(eventMessage); + } + } + + private async Task BuildContextAsync(EventMessage eventMessage, string template) + { + var context = new IntegrationTemplateContext(eventMessage); + + if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue) + { + context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value); + } + + if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue) + { + context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value); + } + + if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue) + { + context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value); + } + + return context; + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs similarity index 91% rename from src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs index ee3a2d5db2..0fab787589 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs @@ -1,4 +1,6 @@ -using Bit.Core.Models.Data; +#nullable enable + +using Bit.Core.Models.Data; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs similarity index 95% rename from src/Core/AdminConsole/Services/Implementations/EventRouteService.cs rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs index a542e75a7b..df0819b409 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventRouteService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs @@ -1,4 +1,6 @@ -using Bit.Core.Models.Data; +#nullable enable + +using Bit.Core.Models.Data; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs new file mode 100644 index 0000000000..a63efac62f --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs @@ -0,0 +1,83 @@ +using System.Diagnostics; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Services; + +public class IntegrationConfigurationDetailsCacheService : BackgroundService, IIntegrationConfigurationDetailsCache +{ + private readonly record struct IntegrationCacheKey(Guid OrganizationId, IntegrationType IntegrationType, EventType? EventType); + private readonly IOrganizationIntegrationConfigurationRepository _repository; + private readonly ILogger _logger; + private readonly TimeSpan _refreshInterval; + private Dictionary> _cache = new(); + + public IntegrationConfigurationDetailsCacheService( + IOrganizationIntegrationConfigurationRepository repository, + GlobalSettings globalSettings, + ILogger logger) + { + _repository = repository; + _logger = logger; + _refreshInterval = TimeSpan.FromMinutes(globalSettings.EventLogging.IntegrationCacheRefreshIntervalMinutes); + } + + public List GetConfigurationDetails( + Guid organizationId, + IntegrationType integrationType, + EventType eventType) + { + var specificKey = new IntegrationCacheKey(organizationId, integrationType, eventType); + var allEventsKey = new IntegrationCacheKey(organizationId, integrationType, null); + + var results = new List(); + + if (_cache.TryGetValue(specificKey, out var specificConfigs)) + { + results.AddRange(specificConfigs); + } + if (_cache.TryGetValue(allEventsKey, out var fallbackConfigs)) + { + results.AddRange(fallbackConfigs); + } + + return results; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await RefreshAsync(); + + var timer = new PeriodicTimer(_refreshInterval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await RefreshAsync(); + } + } + + internal async Task RefreshAsync() + { + var stopwatch = Stopwatch.StartNew(); + try + { + var newCache = (await _repository.GetAllConfigurationDetailsAsync()) + .GroupBy(x => new IntegrationCacheKey(x.OrganizationId, x.IntegrationType, x.EventType)) + .ToDictionary(g => g.Key, g => g.ToList()); + _cache = newCache; + + stopwatch.Stop(); + _logger.LogInformation( + "[IntegrationConfigurationDetailsCacheService] Refreshed successfully: {Count} entries in {Duration}ms", + newCache.Count, + stopwatch.Elapsed.TotalMilliseconds); + } + catch (Exception ex) + { + _logger.LogError("[IntegrationConfigurationDetailsCacheService] Refresh failed: {ex}", ex); + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs new file mode 100644 index 0000000000..b90ea8d16e --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs @@ -0,0 +1,51 @@ +#nullable enable + +using System.Linq.Expressions; +using Bit.Core.Models.Data; + +namespace Bit.Core.Services; + +public delegate bool IntegrationFilter(EventMessage message, object? value); + +public static class IntegrationFilterFactory +{ + public static IntegrationFilter BuildEqualityFilter(string propertyName) + { + var param = Expression.Parameter(typeof(EventMessage), "m"); + var valueParam = Expression.Parameter(typeof(object), "val"); + + var property = Expression.PropertyOrField(param, propertyName); + var typedVal = Expression.Convert(valueParam, typeof(T)); + var body = Expression.Equal(property, typedVal); + + var lambda = Expression.Lambda>(body, param, valueParam); + return new IntegrationFilter(lambda.Compile()); + } + + public static IntegrationFilter BuildInFilter(string propertyName) + { + var param = Expression.Parameter(typeof(EventMessage), "m"); + var valueParam = Expression.Parameter(typeof(object), "val"); + + var property = Expression.PropertyOrField(param, propertyName); + + var method = typeof(Enumerable) + .GetMethods() + .FirstOrDefault(m => + m.Name == "Contains" + && m.GetParameters().Length == 2) + ?.MakeGenericMethod(typeof(T)); + if (method is null) + { + throw new InvalidOperationException("Could not find Contains method."); + } + + var listType = typeof(IEnumerable); + var castedList = Expression.Convert(valueParam, listType); + + var containsCall = Expression.Call(method, castedList, property); + + var lambda = Expression.Lambda>(containsCall, param, valueParam); + return new IntegrationFilter(lambda.Compile()); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs new file mode 100644 index 0000000000..88877c329a --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs @@ -0,0 +1,110 @@ +#nullable enable + +using System.Text.Json; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Models.Data; + +namespace Bit.Core.Services; + +public class IntegrationFilterService : IIntegrationFilterService +{ + private readonly Dictionary _equalsFilters = new(); + private readonly Dictionary _inFilters = new(); + private static readonly string[] _filterableProperties = new[] + { + "UserId", + "InstallationId", + "ProviderId", + "CipherId", + "CollectionId", + "GroupId", + "PolicyId", + "OrganizationUserId", + "ProviderUserId", + "ProviderOrganizationId", + "ActingUserId", + "SecretId", + "ServiceAccountId" + }; + + public IntegrationFilterService() + { + BuildFilters(); + } + + public bool EvaluateFilterGroup(IntegrationFilterGroup group, EventMessage message) + { + var ruleResults = group.Rules?.Select( + rule => EvaluateRule(rule, message) + ) ?? Enumerable.Empty(); + var groupResults = group.Groups?.Select( + innerGroup => EvaluateFilterGroup(innerGroup, message) + ) ?? Enumerable.Empty(); + + var results = ruleResults.Concat(groupResults); + return group.AndOperator ? results.All(r => r) : results.Any(r => r); + } + + private bool EvaluateRule(IntegrationFilterRule rule, EventMessage message) + { + var key = rule.Property; + return rule.Operation switch + { + IntegrationFilterOperation.Equals => _equalsFilters.TryGetValue(key, out var equals) && + equals(message, ToGuid(rule.Value)), + IntegrationFilterOperation.NotEquals => !(_equalsFilters.TryGetValue(key, out var equals) && + equals(message, ToGuid(rule.Value))), + IntegrationFilterOperation.In => _inFilters.TryGetValue(key, out var inList) && + inList(message, ToGuidList(rule.Value)), + IntegrationFilterOperation.NotIn => !(_inFilters.TryGetValue(key, out var inList) && + inList(message, ToGuidList(rule.Value))), + _ => false + }; + } + + private void BuildFilters() + { + foreach (var property in _filterableProperties) + { + _equalsFilters[property] = IntegrationFilterFactory.BuildEqualityFilter(property); + _inFilters[property] = IntegrationFilterFactory.BuildInFilter(property); + } + } + + private static Guid? ToGuid(object? value) + { + if (value is Guid guid) + { + return guid; + } + if (value is string stringValue) + { + return Guid.Parse(stringValue); + } + if (value is JsonElement jsonElement) + { + return jsonElement.GetGuid(); + } + + throw new InvalidCastException("Could not convert value to Guid"); + } + + private static IEnumerable ToGuidList(object? value) + { + if (value is IEnumerable guidList) + { + return guidList; + } + if (value is JsonElement { ValueKind: JsonValueKind.Array } jsonElement) + { + var list = new List(); + foreach (var item in jsonElement.EnumerateArray()) + { + list.Add(ToGuid(item)); + } + return list; + } + + throw new InvalidCastException("Could not convert value to Guid[]"); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md new file mode 100644 index 0000000000..83b59cdec1 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -0,0 +1,476 @@ +# Design goals + +The main goal of event integrations is to easily enable adding new integrations over time without the need +for a lot of custom work to expose events to a new integration. The ability of fan-out offered by AMQP +(either in RabbitMQ or in Azure Service Bus) gives us a way to attach any number of new integrations to the +existing event system without needing to add special handling. By adding a new listener to the existing +pipeline, it gains an independent stream of events without the need for additional broadcast code. + +We want to enable robust handling of failures and retries. By utilizing the two-tier approach +([described below](#two-tier-exchange)), we build in support at the service level for retries. When we add +new integrations, they can focus solely on the integration-specific logic and reporting status, with all the +process of retries and delays managed by the messaging system. + +Another goal is to not only support this functionality in the cloud version, but offer it as well to +self-hosted instances. RabbitMQ provides a lightweight way for self-hosted instances to tie into the event system +using the same robust architecture for integrations without the need for Azure Service Bus. + +Finally, we want to offer organization admins flexibility and control over what events are significant, where +to send events, and the data to be included in the message. The configuration architecture allows Organizations +to customize details of a specific integration; see [Integrations and integration +configurations](#integrations-and-integration-configurations) below for more details on the configuration piece. + +# Architecture + +The entry point for the event integrations is the `IEventWriteService`. By configuring the +`EventIntegrationEventWriteService` as the `EventWriteService`, all events sent to the +service are broadcast on the RabbitMQ or Azure Service Bus message exchange. To abstract away +the specifics of publishing to a specific AMQP provider, an `IEventIntegrationPublisher` +is injected into `EventIntegrationEventWriteService` to handle the publishing of events to the +RabbitMQ or Azure Service Bus service. + +## Two-tier exchange + +When `EventIntegrationEventWriteService` publishes, it posts to the first tier of our two-tier +approach to handling messages. Each tier is represented in the AMQP stack by a separate exchange +(in RabbitMQ terminology) or topic (in Azure Service Bus). + +``` mermaid +flowchart TD + B1[EventService] + B2[EventIntegrationEventWriteService] + B3[Event Exchange / Topic] + B4[EventRepositoryHandler] + B5[WebhookIntegrationHandler] + B6[Events in Database / Azure Tables] + B7[HTTP Server] + B8[SlackIntegrationHandler] + B9[Slack] + B10[EventIntegrationHandler] + B12[Integration Exchange / Topic] + + B1 -->|IEventWriteService| B2 --> B3 + B3-->|EventListenerService| B4 --> B6 + B3-->|EventListenerService| B10 + B3-->|EventListenerService| B10 + B10 --> B12 + B12 -->|IntegrationListenerService| B5 + B12 -->|IntegrationListenerService| B8 + B5 -->|HTTP POST| B7 + B8 -->|HTTP POST| B9 +``` + +### Event tier + +In the first tier, events are broadcast in a fan-out to a series of listeners. The message body +is a JSON representation of an individual `EventMessage` or an array of `EventMessage`. Handlers at +this level are responsible for handling each event or array of events. There are currently two handlers +at this level: + - `EventRepositoryHandler` + - The `EventRepositoryHandler` is responsible for long term storage of events. It receives all events + and stores them via an injected `IEventRepository` into the database. + - This mirrors the behavior of when event integrations are turned off - cloud stores to Azure Tables + and self-hosted is stored to the database. + - `EventIntegrationHandler` + - The `EventIntegrationHandler` is a generic class that is customized to each integration (via the + configuration details of the integration) and is responsible for determining if there's a configuration + for this event / organization / integration, fetching that configuration, and parsing the details of the + event into a template string. + - The `EventIntegrationHandler` uses the injected `IOrganizationIntegrationConfigurationRepository` to pull + the specific set of configuration and template based on the event type, organization, and integration type. + This configuration is what determines if an integration should be sent, what details are necessary for sending + it, and the actual message to send. + - The output of `EventIntegrationHandler` is a new `IntegrationMessage`, with the details of this + the configuration necessary to interact with the integration and the message to send (with all the event + details incorporated), published to the integration level of the message bus. + +### Integration tier + +At the integration level, messages are JSON representations of `IIntegrationMessage` - specifically they +will be concrete types of the generic `IntegrationMessage` where `` is the configuration details of the +specific integration for which they've been sent. These messages represent the details required for +sending a specific event to a specific integration, including handling retries and delays. + +Handlers at the integration level are tied directly to the integration (e.g. `SlackIntegrationHandler`, +`WebhookIntegrationHandler`). These handlers take in `IntegrationMessage` and output +`IntegrationHandlerResult`, which tells the listener the outcome of the integration (e.g. success / fail, +if it can be retried and any minimum delay that should occur). This makes them easy to unit test in isolation +without any of the concerns of AMQP or messaging. + +The listeners at this level are responsible for firing off the handler when a new message comes in and then +taking the correct action based on the result. Successful results simply acknowledge the message and resolve. +Failures will either be sent to the dead letter queue (DLQ) or re-published for retry after the correct amount of delay. + +### Retries + +One of the goals of introducing the integration level is to simplify and enable the process of multiple retries +for a specific event integration. For instance, if a service is temporarily down, we don't want one of our handlers +blocking the rest of the queue while it waits to retry. In addition, we don't want to retry _all_ integrations for a +specific event if only one integration fails nor do we want to re-lookup the configuration details. By splitting +out the `IntegrationMessage` with the configuration, message, and details around retries, we can process each +event / integration individually and retry easily. + +When the `IntegrationHandlerResult.Success` is set to `false` (indicating that the integration attempt failed) the +`Retryable` flag tells the listener whether this failure is temporary or final. If the `Retryable` is `false`, then +the message is immediately sent to the DLQ. If it is `true`, the listener uses the `ApplyRetry(DateTime)` method +in `IntegrationMessage` which handles both incrementing the `RetryCount` and updating the `DelayUntilDate` using +the provided DateTime, but also adding exponential backoff (based on `RetryCount`) and jitter. The listener compares +the `RetryCount` in the `IntegrationMessage` to see if it's over the `MaxRetries` defined in Global Settings. If it +is over the `MaxRetries`, the message is sent to the DLQ. Otherwise, it is scheduled for retry. + +``` mermaid +flowchart TD +A[Success == false] --> B{Retryable?} + B -- No --> C[Send to Dead Letter Queue DLQ] + B -- Yes --> D[Check RetryCount vs MaxRetries] + D -->|RetryCount >= MaxRetries| E[Send to Dead Letter Queue DLQ] + D -->|RetryCount < MaxRetries| F[Schedule for Retry] +``` + +Azure Service Bus supports scheduling messages as part of its core functionality. Retries are scheduled to a specific +time and then ASB holds the message and publishes it at the correct time. + +#### RabbitMQ retry options + +For RabbitMQ (which will be used by self-host only), we have two different options. The `useDelayPlugin` flag in +`GlobalSettings.RabbitMqSettings` determines which one is used. If it is set to `true`, we use the delay plugin. It +defaults to `false` which indicates we should use retry queues with a timing check. + +1. Delay plugin + - [Delay plugin GitHub repo](https://github.com/rabbitmq/rabbitmq-delayed-message-exchange) + - This plugin enables a delayed message exchange in RabbitMQ that supports delaying a message for an amount + of time specified in a special header. + - This allows us to forego using any retry queues and rely instead on the delay exchange. When a message is + marked with the header it gets published to the exchange and the exchange handles all the functionality of + holding it until the appropriate time (similar to ASB's built-in support). + - The plugin must be setup and enabled before turning this option on (which is why it defaults to off). + +2. Retry queues + timing check + - If the delay plugin setting is off, we push the message to a retry queue which has a fixed amount of time before + it gets re-published back to the main queue. + - When a message comes off the queue, we check to see if the `DelayUntilDate` has already passed. + - If it has passed, we then handle the integration normally and retry the request. + - If it is still in the future, we put the message back on the retry queue for an additional wait. + - While this does use extra processing, it gives us better support for honoring the delays even if the delay plugin + isn't enabled. Since this solution is only intended for self-host, it should be a pretty minimal impact with short + delays and a small number of retries. + +## Listener / Handler pattern + +To make it easy to support multiple AMQP services (RabbitMQ and Azure Service Bus), the act +of listening to the stream of messages is decoupled from the act of responding to a message. + +### Listeners + +- Listeners handle the details of the communication platform (i.e. RabbitMQ and Azure Service Bus). +- There is one listener for each platform (RabbitMQ / ASB) for each of the two levels - i.e. one event listener + and one integration listener. +- Perform all the aspects of setup / teardown, subscription, message acknowledgement, etc. for the messaging platform, + but do not directly process any events themselves. Instead, they delegate to the handler with which they + are configured. +- Multiple instances can be configured to run independently, each with its own handler and + subscription / queue. + +### Handlers + +- One handler per queue / subscription (e.g. per integration at the integration level). +- Completely isolated from and know nothing of the messaging platform in use. This allows them to be + freely reused across different communication platforms. +- Perform all aspects of handling an event. +- Allows them to be highly testable as they are isolated and decoupled from the more complicated + aspects of messaging. + +This combination allows for a configuration inside of `ServiceCollectionExtensions.cs` that pairs +instances of the listener service for the currently running messaging platform with any number of +handlers. It also allows for quick development of new handlers as they are focused only on the +task of handling a specific event. + +## Publishers and Services + +Listeners (and `EventIntegrationHandler`) interact with the messaging system via the `IEventPublisher` interface, +which is backed by a RabbitMQ and ASB specific service. By placing most of the messaging platform details in the +service layer, we are able to handle common things like configuring the connection, binding or creating a specific +queue, etc. in one place. The `IRabbitMqService` and `IAzureServiceBusService` implement the `IEventPublisher` +interface and therefore can also handle directly all the message publishing functionality. + +## Integrations and integration configurations + +Organizations can configure integration configurations to send events to different endpoints -- each +handler maps to a specific integration and checks for the configuration when it receives an event. +Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event Collector (HEC). + +### `OrganizationIntegration` + +- The top-level object that enables a specific integration for the organization. +- Includes any properties that apply to the entire integration across all events. + - For Slack, it consists of the token: `{ "Token": "xoxb-token-from-slack" }`. + - For webhooks, it is optional. Webhooks can either be configured at this level or the configuration level, + but the configuration level takes precedence. However, even though it is optional, an organization must + have a webhook `OrganizationIntegration` (even will a `null` `Configuration`) to enable configuration + via `OrganizationIntegrationConfiguration`. + - For HEC, it consists of the scheme, token, and URI: + +```json + { + "Scheme": "Bearer", + "Token": "Auth-token-from-HEC-service", + "Uri": "https://example.com/api" + } +``` + +### `OrganizationIntegrationConfiguration` + +- This contains the configurations specific to each `EventType` for the integration. +- `Configuration` contains the event-specific configuration. + - For Slack, this would contain what channel to send the message to: `{ "channelId": "C123456" }` + - For webhooks, this is the URL the request should be sent to: `{ "url": "https://api.example.com" }` + - Optionally this also can include a `Scheme` and `Token` if this webhook needs Authentication. + - As stated above, all of this information can be specified here or at the `OrganizationIntegration` + level, but any properties declared here will take precedence over the ones above. + - For HEC, this must be null. HEC is configured only at the `OrganizationIntegration` level. +- `Template` contains a template string that is expected to be filled in with the contents of the actual event. + - The tokens in the string are wrapped in `#` characters. For instance, the UserId would be `#UserId#`. + - The `IntegrationTemplateProcessor` does the actual work of replacing these tokens with introspected values from + the provided `EventMessage`. + - The template does not enforce any structure — it could be a freeform text message to send via Slack, or a + JSON body to send via webhook; it is simply stored and used as a string for the most flexibility. + +### `OrganizationIntegrationConfigurationDetails` + +- This is the combination of both the `OrganizationIntegration` and `OrganizationIntegrationConfiguration` into + a single object. The combined contents tell the integration's handler all the details needed to send to an + external service. +- `OrganizationIntegrationConfiguration` takes precedence over `OrganizationIntegration` - any keys present in + both will receive the value declared in `OrganizationIntegrationConfiguration`. +- An array of `OrganizationIntegrationConfigurationDetails` is what the `EventIntegrationHandler` fetches from + the database to determine what to publish at the integration level. + +## Filtering + +In addition to the ability to configure integrations mentioned above, organization admins can +also add `Filters` stored in the `OrganizationIntegrationConfiguration`. Filters are completely +optional and as simple or complex as organization admins want to make them. These are stored in +the database as JSON and serialized into an `IntegrationFilterGroup`. This is then passed to +the `IntegrationFilterService`, which evaluates it to a `bool`. If it's `true`, the integration +proceeds as above. If it's `false`, we ignore this event and do not route it to the integration +level. + +### `IntegrationFilterGroup` + +Logical AND / OR grouping of a number of rules and other subgroups. + +| Property | Description | +|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `AndOperator` | Indicates whether **all** (`true`) or **any** (`false`) of the `Rules` and `Groups` must be true. This applies to _both_ the inner group and the list of rules; for instance, if this group contained Rule1 and Rule2 as well as Group1 and Group2:

`true`: `Rule1 && Rule2 && Group1 && Group2`
`false`: `Rule1 \|\| Rule2 \|\| Group1 \|\| Group2` | +| `Rules` | A list of `IntegrationFilterRule`. Can be null or empty, in which case it will return `true`. | +| `Groups` | A list of nested `IntegrationFilterGroup`. Can be null or empty, in which case it will return `true`. | + +### `IntegrationFilterRule` + +The core of the filtering framework to determine if the data in this specific EventMessage +matches the data for which the filter is searching. + +| Property | Description | +|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Property` | The property on `EventMessage` to evaluate (e.g., `CollectionId`). | +| `Operation` | The comparison to perform between the property and `Value`.

**Supported operations:**
• `Equals`: `Guid` equals `Value`
• `NotEquals`: logical inverse of `Equals`
• `In`: `Guid` is in `Value` list
• `NotIn`: logical inverse of `In` | +| `Value` | The comparison value. Type depends on `Operation`:
• `Equals`, `NotEquals`: `Guid`
• `In`, `NotIn`: list of `Guid` | + +```mermaid +graph TD + A[IntegrationFilterGroup] + A -->|Has 0..many| B1[IntegrationFilterRule] + A --> D1[And Operator] + A -->|Has 0..many| C1[Nested IntegrationFilterGroup] + + B1 --> B2[Property: string] + B1 --> B3[Operation: Equals/In/DateBefore/DateAfter] + B1 --> B4[Value: object?] + + C1 -->|Has many| B1_2[IntegrationFilterRule] + C1 -->|Can contain| C2[IntegrationFilterGroup...] +``` +## Caching + +To reduce database load and improve performance, integration configurations are cached in-memory as a Dictionary +with a periodic load of all configurations. Without caching, each incoming `EventMessage` would trigger a database +query to retrieve the relevant `OrganizationIntegrationConfigurationDetails`. + +By loading all configurations into memory on a fixed interval, we ensure: + +- Consistent performance for reads. +- Reduced database pressure. +- Predictable refresh timing, independent of event activity. + +### Architecture / Design + +- The cache is read-only for consumers. It is only updated in bulk by a background refresh process. +- The cache is fully replaced on each refresh to avoid locking or partial state. +- Reads return a `List` for a given key or an empty list if no + match exists. +- Failures or delays in the loading process do not affect the existing cache state. The cache will continue serving + the last known good state until the update replaces the whole cache. + +### Background Refresh + +A hosted service (`IntegrationConfigurationDetailsCacheService`) runs in the background and: + +- Loads all configuration records at application startup. +- Refreshes the cache on a configurable interval. +- Logs timing and entry count on success. +- Logs exceptions on failure without disrupting application flow. + +# Building a new integration + +These are all the pieces required in the process of building out a new integration. For +clarity in naming, these assume a new integration called "Example". + +## IntegrationType + +Add a new type to `IntegrationType` for the new integration. + +## Configuration Models + +The configuration models are the classes that will determine what is stored in the database for +`OrganizationIntegration` and `OrganizationIntegrationConfiguration`. The `Configuration` columns are the +serialized version of the corresponding objects and represent the coonfiguration details for this integration +and event type. + +1. `ExampleIntegration` + - Configuration details for the whole integration (e.g. a token in Slack). + - Applies to every event type configuration defined for this integration. + - Maps to the JSON structure stored in `Configuration` in ``OrganizationIntegration`. +2. `ExampleIntegrationConfiguration` + - Configuration details that could change from event to event (e.g. channelId in Slack). + - Maps to the JSON structure stored in `Configuration` in `OrganizationIntegrationConfiguration`. +3. `ExampleIntegrationConfigurationDetails` + - Combined configuration of both Integration _and_ IntegrationConfiguration. + - This will be the deserialized version of the `MergedConfiguration` in + `OrganizationIntegrationConfigurationDetails`. + +## Request Models + +1. Add a new case to the switch method in `OrganizationIntegrationRequestModel.Validate`. +2. Add a new case to the switch method in `OrganizationIntegrationConfigurationRequestModel.IsValidForType`. + +## Integration Handler + +e.g. `ExampleIntegrationHandler` +- This is where the actual code will go to perform the integration (i.e. send an HTTP request, etc.). +- Handlers receive an `IntegrationMessage` where `` is the `ExampleIntegrationConfigurationDetails` + defined above. This has the Configuration as well as the rendered template message to be sent. +- Handlers return an `IntegrationHandlerResult` with details about if the request - success / failure, + if it can be retried, when it should be delayed until, etc. +- The scope of the handler is simply to do the integration and report the result. + Everything else (such as how many times to retry, when to retry, what to do with failures) + is done in the Listener. + +## GlobalSettings + +### RabbitMQ +Add the queue names for the integration. These are typically set with a default value so +that they will be created when first accessed in code by RabbitMQ. + +1. `ExampleEventQueueName` +2. `ExampleIntegrationQueueName` +3. `ExampleIntegrationRetryQueueName` + +### Azure Service Bus +Add the subscription names to use for ASB for this integration. Similar to RabbitMQ a +default value is provided so that we don't require configuring it in secrets but allow +it to be overridden. **However**, unlike RabbitMQ these subscriptions must exist prior +to the code accessing them. They will not be created on the fly. See [Deploying a new +integration](#deploying-a-new-integration) below + +1. `ExmpleEventSubscriptionName` +2. `ExmpleIntegrationSubscriptionName` + +#### Service Bus Emulator, local config +In order to create ASB resources locally, we need to also update the `servicebusemulator_config.json` file +to include any new subscriptions. +- Under the existing event topic (`event-logging`) add a subscription for the event level for this + new integration (`events-example-subscription`). +- Under the existing integration topic (`event-integrations`) add a new subscription for the integration + level messages (`integration-example-subscription`). + - Copy the correlation filter from the other integration level subscriptions. It should filter based on + the `IntegrationType.ToRoutingKey`, or in this example `example`. + +These names added here are what must match the values provided in the secrets or the defaults provided +in Global Settings. This must be in place (and the local ASB emulator restarted) before you can use any +code locally that accesses ASB resources. + +## ListenerConfiguration + +New integrations will need their own subclass of `ListenerConfiguration` which also conforms to +`IIntegrationListenerConfiguration`. This class provides a way of accessing the previously configured +RabbitMQ queues and ASB subscriptions by referring to the values created in `GlobalSettings`. This new +listener configuration will be used to type the listener and provide the means to access the necessary +configurations for the integration. + +## ServiceCollectionExtensions + +In our `ServiceCollectionExtensions`, we pull all the above pieces together to start listeners on each message +tier with handlers to process the integration. + +The core method for all event integration setup is `AddEventIntegrationServices`. This method is called by +both of the add listeners methods, which ensures that we have one common place to set up cross-messaging-platform +dependencies and integrations. For instance, `SlackIntegrationHandler` needs a `SlackService`, so +`AddEventIntegrationServices` has a call to `AddSlackService`. Same thing for webhooks when it +comes to defining a custom HttpClient by name. + +1. In `AddEventIntegrationServices` create the listener configuration: + +``` csharp + var exampleConfiguration = new ExampleListenerConfiguration(globalSettings); +``` + +2. Add the integration to both the RabbitMQ and ASB specific declarations: + +``` csharp + services.AddRabbitMqIntegration(exampleConfiguration); +``` + +and + +``` csharp + services.AddAzureServiceBusIntegration(exampleConfiguration); +``` + + +# Deploying a new integration + +## RabbitMQ + +RabbitMQ dynamically creates queues and exchanges when they are first accessed in code. +Therefore, there is no need to manually create queues when deploying a new integration. +They can be created and configured ahead of time, but it's not required. Note that once +they are created, if any configurations need to be changed, the queue or exchange must be +deleted and recreated. + +## Azure Service Bus + +Unlike RabbitMQ, ASB resources **must** be allocated before the code accesses them and +will not be created on the fly. This means that any subscriptions needed for a new +integration must be created in ASB before that code is deployed. + +The two subscriptions created above in Global Settings and `servicebusemulator_config.json` +need to be created in the Azure portal or CLI for the environment before deploying the +code. + +1. `ExmpleEventSubscriptionName` + - This subscription is a fan-out subscription from the main event topic. + - As such, it will start receiving all the events as soon as it is declared. + - This can create a backlog before the integration-specific handler is declared and deployed. + - One strategy to avoid this is to create the subscription with a false filter (e.g. `1 = 0`). + - This will create the subscription, but the filter will ensure that no messages + actually land in the subscription. + - Code can be deployed that references the subscription, because the subscription + legitimately exists (it is simply empty). + - When the code is in place, and we're ready to start receiving messages on the new + integration, we simply remove the filter to return the subscription to receiving + all messages via fan-out. +2. `ExmpleIntegrationSubscriptionName` + - This subscription must be created before the new integration code can be deployed. + - However, it is not fan-out, but rather a filter based on the `IntegrationType.ToRoutingKey`. + - Therefore, it won't start receiving messages until organizations have active configurations. + This means there's no risk of building up a backlog by declaring it ahead of time. diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs new file mode 100644 index 0000000000..5b089b06a6 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs @@ -0,0 +1,76 @@ +#nullable enable + +using System.Text; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Microsoft.Extensions.Logging; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; + +namespace Bit.Core.Services; + +public class RabbitMqEventListenerService : EventLoggingListenerService + where TConfiguration : IEventListenerConfiguration +{ + private readonly Lazy> _lazyChannel; + private readonly string _queueName; + private readonly IRabbitMqService _rabbitMqService; + + public RabbitMqEventListenerService( + IEventMessageHandler handler, + TConfiguration configuration, + IRabbitMqService rabbitMqService, + ILoggerFactory loggerFactory) + : base(handler, CreateLogger(loggerFactory, configuration)) + { + _queueName = configuration.EventQueueName; + _rabbitMqService = rabbitMqService; + _lazyChannel = new Lazy>(() => _rabbitMqService.CreateChannelAsync()); + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + await _rabbitMqService.CreateEventQueueAsync(_queueName, cancellationToken); + await base.StartAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + var channel = await _lazyChannel.Value; + var consumer = new AsyncEventingBasicConsumer(channel); + consumer.ReceivedAsync += async (_, eventArgs) => { await ProcessReceivedMessageAsync(eventArgs); }; + + await channel.BasicConsumeAsync(_queueName, autoAck: true, consumer: consumer, cancellationToken: cancellationToken); + } + + internal async Task ProcessReceivedMessageAsync(BasicDeliverEventArgs eventArgs) + { + await ProcessReceivedMessageAsync( + Encoding.UTF8.GetString(eventArgs.Body.Span), + eventArgs.BasicProperties.MessageId); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + if (_lazyChannel.IsValueCreated) + { + var channel = await _lazyChannel.Value; + await channel.CloseAsync(cancellationToken); + } + await base.StopAsync(cancellationToken); + } + + public override void Dispose() + { + if (_lazyChannel.IsValueCreated && _lazyChannel.Value.IsCompletedSuccessfully) + { + _lazyChannel.Value.Result.Dispose(); + } + base.Dispose(); + } + + private static ILogger CreateLogger(ILoggerFactory loggerFactory, TConfiguration configuration) + { + return loggerFactory.CreateLogger( + categoryName: $"Bit.Core.Services.RabbitMqEventListenerService.{configuration.EventQueueName}"); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs new file mode 100644 index 0000000000..59c8782985 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs @@ -0,0 +1,151 @@ +#nullable enable + +using System.Text; +using System.Text.Json; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; + +namespace Bit.Core.Services; + +public class RabbitMqIntegrationListenerService : BackgroundService + where TConfiguration : IIntegrationListenerConfiguration +{ + private readonly int _maxRetries; + private readonly string _queueName; + private readonly string _routingKey; + private readonly string _retryQueueName; + private readonly IIntegrationHandler _handler; + private readonly Lazy> _lazyChannel; + private readonly IRabbitMqService _rabbitMqService; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public RabbitMqIntegrationListenerService( + IIntegrationHandler handler, + TConfiguration configuration, + IRabbitMqService rabbitMqService, + ILoggerFactory loggerFactory, + TimeProvider timeProvider) + { + _handler = handler; + _maxRetries = configuration.MaxRetries; + _routingKey = configuration.RoutingKey; + _retryQueueName = configuration.IntegrationRetryQueueName; + _queueName = configuration.IntegrationQueueName; + _rabbitMqService = rabbitMqService; + _timeProvider = timeProvider; + _lazyChannel = new Lazy>(() => _rabbitMqService.CreateChannelAsync()); + _logger = loggerFactory.CreateLogger( + categoryName: $"Bit.Core.Services.RabbitMqIntegrationListenerService.{configuration.IntegrationQueueName}"); ; + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + await _rabbitMqService.CreateIntegrationQueuesAsync( + _queueName, + _retryQueueName, + _routingKey, + cancellationToken: cancellationToken); + + await base.StartAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + var channel = await _lazyChannel.Value; + var consumer = new AsyncEventingBasicConsumer(channel); + consumer.ReceivedAsync += async (_, ea) => + { + await ProcessReceivedMessageAsync(ea, cancellationToken); + }; + + await channel.BasicConsumeAsync(queue: _queueName, autoAck: false, consumer: consumer, cancellationToken: cancellationToken); + } + + internal async Task ProcessReceivedMessageAsync(BasicDeliverEventArgs ea, CancellationToken cancellationToken) + { + var channel = await _lazyChannel.Value; + try + { + var json = Encoding.UTF8.GetString(ea.Body.Span); + + // Determine if the message came off of the retry queue too soon + // If so, place it back on the retry queue + var integrationMessage = JsonSerializer.Deserialize(json); + if (integrationMessage is not null && + integrationMessage.DelayUntilDate.HasValue && + integrationMessage.DelayUntilDate.Value > _timeProvider.GetUtcNow().UtcDateTime) + { + await _rabbitMqService.RepublishToRetryQueueAsync(channel, ea); + await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); + return; + } + + var result = await _handler.HandleAsync(json); + var message = result.Message; + + if (result.Success) + { + // Successful integration send. Acknowledge message delivery and return + await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); + return; + } + + if (result.Retryable) + { + // Integration failed, but is retryable - apply delay and check max retries + message.ApplyRetry(result.DelayUntilDate); + + if (message.RetryCount < _maxRetries) + { + // Publish message to the retry queue. It will be re-published for retry after a delay + await _rabbitMqService.PublishToRetryAsync(channel, message, cancellationToken); + } + else + { + // Exceeded the max number of retries; fail and send to dead letter queue + await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken); + _logger.LogWarning("Max retry attempts reached. Sent to DLQ."); + } + } + else + { + // Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries + await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken); + _logger.LogWarning("Non-retryable failure. Sent to DLQ."); + } + + // Message has been sent to retry or dead letter queues. + // Acknowledge receipt so Rabbit knows it's been processed + await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); + } + catch (Exception ex) + { + // Unknown error occurred. Acknowledge so Rabbit doesn't keep attempting. Log the error + _logger.LogError(ex, "Unhandled error processing integration message."); + await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + if (_lazyChannel.IsValueCreated) + { + var channel = await _lazyChannel.Value; + await channel.CloseAsync(cancellationToken); + } + await base.StopAsync(cancellationToken); + } + + public override void Dispose() + { + if (_lazyChannel.IsValueCreated && _lazyChannel.Value.IsCompletedSuccessfully) + { + _lazyChannel.Value.Result.Dispose(); + } + base.Dispose(); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs new file mode 100644 index 0000000000..20ae31a113 --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs @@ -0,0 +1,244 @@ +#nullable enable + +using System.Text; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Enums; +using Bit.Core.Settings; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; + +namespace Bit.Core.Services; + +public class RabbitMqService : IRabbitMqService +{ + private const string _deadLetterRoutingKey = "dead-letter"; + + private readonly ConnectionFactory _factory; + private readonly Lazy> _lazyConnection; + private readonly string _deadLetterQueueName; + private readonly string _eventExchangeName; + private readonly string _integrationExchangeName; + private readonly int _retryTiming; + private readonly bool _useDelayPlugin; + + public RabbitMqService(GlobalSettings globalSettings) + { + _factory = new ConnectionFactory + { + HostName = globalSettings.EventLogging.RabbitMq.HostName, + UserName = globalSettings.EventLogging.RabbitMq.Username, + Password = globalSettings.EventLogging.RabbitMq.Password + }; + _deadLetterQueueName = globalSettings.EventLogging.RabbitMq.IntegrationDeadLetterQueueName; + _eventExchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName; + _integrationExchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName; + _retryTiming = globalSettings.EventLogging.RabbitMq.RetryTiming; + _useDelayPlugin = globalSettings.EventLogging.RabbitMq.UseDelayPlugin; + + _lazyConnection = new Lazy>(CreateConnectionAsync); + } + + public async Task CreateChannelAsync(CancellationToken cancellationToken = default) + { + var connection = await _lazyConnection.Value; + return await connection.CreateChannelAsync(cancellationToken: cancellationToken); + } + + public async Task CreateEventQueueAsync(string queueName, CancellationToken cancellationToken = default) + { + using var channel = await CreateChannelAsync(cancellationToken); + await channel.QueueDeclareAsync(queue: queueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null, + cancellationToken: cancellationToken); + await channel.QueueBindAsync(queue: queueName, + exchange: _eventExchangeName, + routingKey: string.Empty, + cancellationToken: cancellationToken); + } + + public async Task CreateIntegrationQueuesAsync( + string queueName, + string retryQueueName, + string routingKey, + CancellationToken cancellationToken = default) + { + using var channel = await CreateChannelAsync(cancellationToken); + var retryRoutingKey = $"{routingKey}-retry"; + + // Declare main integration queue + await channel.QueueDeclareAsync( + queue: queueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null, + cancellationToken: cancellationToken); + await channel.QueueBindAsync( + queue: queueName, + exchange: _integrationExchangeName, + routingKey: routingKey, + cancellationToken: cancellationToken); + + if (!_useDelayPlugin) + { + // Declare retry queue (Configurable TTL, dead-letters back to main queue) + // Only needed if NOT using delay plugin + await channel.QueueDeclareAsync(queue: retryQueueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: new Dictionary + { + { "x-dead-letter-exchange", _integrationExchangeName }, + { "x-dead-letter-routing-key", routingKey }, + { "x-message-ttl", _retryTiming } + }, + cancellationToken: cancellationToken); + await channel.QueueBindAsync(queue: retryQueueName, + exchange: _integrationExchangeName, + routingKey: retryRoutingKey, + cancellationToken: cancellationToken); + } + } + + public async Task PublishAsync(IIntegrationMessage message) + { + var routingKey = message.IntegrationType.ToRoutingKey(); + await using var channel = await CreateChannelAsync(); + + var body = Encoding.UTF8.GetBytes(message.ToJson()); + var properties = new BasicProperties + { + MessageId = message.MessageId, + Persistent = true + }; + + await channel.BasicPublishAsync( + exchange: _integrationExchangeName, + mandatory: true, + basicProperties: properties, + routingKey: routingKey, + body: body); + } + + public async Task PublishEventAsync(string body) + { + await using var channel = await CreateChannelAsync(); + var properties = new BasicProperties + { + MessageId = Guid.NewGuid().ToString(), + Persistent = true + }; + + await channel.BasicPublishAsync( + exchange: _eventExchangeName, + mandatory: true, + basicProperties: properties, + routingKey: string.Empty, + body: Encoding.UTF8.GetBytes(body)); + } + + public async Task PublishToRetryAsync(IChannel channel, IIntegrationMessage message, CancellationToken cancellationToken) + { + var routingKey = message.IntegrationType.ToRoutingKey(); + var retryRoutingKey = $"{routingKey}-retry"; + var properties = new BasicProperties + { + Persistent = true, + MessageId = message.MessageId, + Headers = _useDelayPlugin && message.DelayUntilDate.HasValue ? + new Dictionary + { + ["x-delay"] = Math.Max((int)(message.DelayUntilDate.Value - DateTime.UtcNow).TotalMilliseconds, 0) + } : + null + }; + + await channel.BasicPublishAsync( + exchange: _integrationExchangeName, + routingKey: _useDelayPlugin ? routingKey : retryRoutingKey, + mandatory: true, + basicProperties: properties, + body: Encoding.UTF8.GetBytes(message.ToJson()), + cancellationToken: cancellationToken); + } + + public async Task PublishToDeadLetterAsync( + IChannel channel, + IIntegrationMessage message, + CancellationToken cancellationToken) + { + var properties = new BasicProperties + { + MessageId = message.MessageId, + Persistent = true + }; + + await channel.BasicPublishAsync( + exchange: _integrationExchangeName, + mandatory: true, + basicProperties: properties, + routingKey: _deadLetterRoutingKey, + body: Encoding.UTF8.GetBytes(message.ToJson()), + cancellationToken: cancellationToken); + } + + public async Task RepublishToRetryQueueAsync(IChannel channel, BasicDeliverEventArgs eventArgs) + { + await channel.BasicPublishAsync( + exchange: _integrationExchangeName, + routingKey: eventArgs.RoutingKey, + mandatory: true, + basicProperties: new BasicProperties(eventArgs.BasicProperties), + body: eventArgs.Body); + } + + public async ValueTask DisposeAsync() + { + if (_lazyConnection.IsValueCreated) + { + var connection = await _lazyConnection.Value; + await connection.DisposeAsync(); + } + } + + private async Task CreateConnectionAsync() + { + var connection = await _factory.CreateConnectionAsync(); + using var channel = await connection.CreateChannelAsync(); + + // Declare Exchanges + await channel.ExchangeDeclareAsync(exchange: _eventExchangeName, type: ExchangeType.Fanout, durable: true); + if (_useDelayPlugin) + { + await channel.ExchangeDeclareAsync( + exchange: _integrationExchangeName, + type: "x-delayed-message", + durable: true, + arguments: new Dictionary + { + { "x-delayed-type", "direct" } + } + ); + } + else + { + await channel.ExchangeDeclareAsync(exchange: _integrationExchangeName, type: ExchangeType.Direct, durable: true); + } + + // Declare dead letter queue for Integration exchange + await channel.QueueDeclareAsync(queue: _deadLetterQueueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null); + await channel.QueueBindAsync(queue: _deadLetterQueueName, + exchange: _integrationExchangeName, + routingKey: _deadLetterRoutingKey); + + return connection; + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs similarity index 75% rename from src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs index 134e93e838..6f55c0cf9c 100644 --- a/src/Core/AdminConsole/Services/Implementations/SlackIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs @@ -1,4 +1,6 @@ -using Bit.Core.AdminConsole.Models.Data.Integrations; +#nullable enable + +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; namespace Bit.Core.Services; @@ -9,9 +11,9 @@ public class SlackIntegrationHandler( public override async Task HandleAsync(IntegrationMessage message) { await slackService.SendSlackMessageByChannelIdAsync( - message.Configuration.token, + message.Configuration.Token, message.RenderedTemplate, - message.Configuration.channelId + message.Configuration.ChannelId ); return new IntegrationHandlerResult(success: true, message: message); diff --git a/src/Core/AdminConsole/Services/Implementations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs similarity index 92% rename from src/Core/AdminConsole/Services/Implementations/SlackService.cs rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs index effcfdf1ce..3f82217830 100644 --- a/src/Core/AdminConsole/Services/Implementations/SlackService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs @@ -1,4 +1,6 @@ -using System.Net.Http.Headers; +#nullable enable + +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Web; using Bit.Core.Models.Slack; @@ -22,7 +24,7 @@ public class SlackService( public async Task GetChannelIdAsync(string token, string channelName) { - return (await GetChannelIdsAsync(token, [channelName])).FirstOrDefault(); + return (await GetChannelIdsAsync(token, [channelName])).FirstOrDefault() ?? string.Empty; } public async Task> GetChannelIdsAsync(string token, List channelNames) @@ -58,7 +60,7 @@ public class SlackService( } else { - logger.LogError("Error getting Channel Ids: {Error}", result.Error); + logger.LogError("Error getting Channel Ids: {Error}", result?.Error ?? "Unknown Error"); nextCursor = string.Empty; } @@ -89,7 +91,7 @@ public class SlackService( new KeyValuePair("redirect_uri", redirectUrl) })); - SlackOAuthResponse result; + SlackOAuthResponse? result; try { result = await tokenResponse.Content.ReadFromJsonAsync(); @@ -99,7 +101,7 @@ public class SlackService( result = null; } - if (result == null) + if (result is null) { logger.LogError("Error obtaining token via OAuth: Unknown error"); return string.Empty; @@ -130,6 +132,11 @@ public class SlackService( var response = await _httpClient.SendAsync(request); var result = await response.Content.ReadFromJsonAsync(); + if (result is null) + { + logger.LogError("Error retrieving Slack user ID: Unknown error"); + return string.Empty; + } if (!result.Ok) { logger.LogError("Error retrieving Slack user ID: {Error}", result.Error); @@ -151,6 +158,11 @@ public class SlackService( var response = await _httpClient.SendAsync(request); var result = await response.Content.ReadFromJsonAsync(); + if (result is null) + { + logger.LogError("Error opening DM channel: Unknown error"); + return string.Empty; + } if (!result.Ok) { logger.LogError("Error opening DM channel: {Error}", result.Error); diff --git a/src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs similarity index 64% rename from src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs rename to src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs index 5f9898afe8..99cad65efa 100644 --- a/src/Core/AdminConsole/Services/Implementations/WebhookIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs @@ -1,13 +1,16 @@ -using System.Globalization; -using System.Net; -using System.Text; -using Bit.Core.AdminConsole.Models.Data.Integrations; +#nullable enable -#nullable enable +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; namespace Bit.Core.Services; -public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory) +public class WebhookIntegrationHandler( + IHttpClientFactory httpClientFactory, + TimeProvider timeProvider) : IntegrationHandlerBase { private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); @@ -16,8 +19,16 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory) public override async Task HandleAsync(IntegrationMessage message) { - var content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync(message.Configuration.url, content); + var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Uri); + request.Content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json"); + if (!string.IsNullOrEmpty(message.Configuration.Scheme)) + { + request.Headers.Authorization = new AuthenticationHeaderValue( + scheme: message.Configuration.Scheme, + parameter: message.Configuration.Token + ); + } + var response = await _httpClient.SendAsync(request); var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message); switch (response.StatusCode) @@ -29,7 +40,7 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory) case HttpStatusCode.ServiceUnavailable: case HttpStatusCode.GatewayTimeout: result.Retryable = true; - result.FailureReason = response.ReasonPhrase; + result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}"; if (response.Headers.TryGetValues("Retry-After", out var values)) { @@ -37,7 +48,7 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory) if (int.TryParse(value, out var seconds)) { // Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds. - result.DelayUntilDate = DateTime.UtcNow.AddSeconds(seconds); + result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; } else if (DateTimeOffset.TryParseExact(value, "r", // "r" is the round-trip format: RFC1123 @@ -52,7 +63,7 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory) break; default: result.Retryable = false; - result.FailureReason = response.ReasonPhrase; + result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; break; } diff --git a/src/Core/AdminConsole/Services/Implementations/EventService.cs b/src/Core/AdminConsole/Services/Implementations/EventService.cs index 0cecda61a7..e56b3aced4 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Interfaces; using Bit.Core.AdminConsole.Models.Data.Provider; @@ -409,9 +412,30 @@ public class EventService : IEventService await _eventWriteService.CreateAsync(e); } - public async Task LogServiceAccountSecretEventAsync(Guid serviceAccountId, Secret secret, EventType type, DateTime? date = null) + public async Task LogUserSecretsEventAsync(Guid userId, IEnumerable secrets, EventType type, DateTime? date = null) { - await LogServiceAccountSecretsEventAsync(serviceAccountId, new[] { secret }, type, date); + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + + foreach (var secret in secrets) + { + if (!CanUseEvents(orgAbilities, secret.OrganizationId)) + { + continue; + } + + var e = new EventMessage(_currentContext) + { + OrganizationId = secret.OrganizationId, + Type = type, + SecretId = secret.Id, + UserId = userId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + } + + await _eventWriteService.CreateManyAsync(eventMessages); } public async Task LogServiceAccountSecretsEventAsync(Guid serviceAccountId, IEnumerable secrets, EventType type, DateTime? date = null) @@ -462,13 +486,13 @@ public class EventService : IEventService private bool CanUseEvents(IDictionary orgAbilities, Guid orgId) { - return orgAbilities != null && orgAbilities.ContainsKey(orgId) && - orgAbilities[orgId].Enabled && orgAbilities[orgId].UseEvents; + return orgAbilities != null && orgAbilities.TryGetValue(orgId, out var orgAbility) && + orgAbility.Enabled && orgAbility.UseEvents; } private bool CanUseProviderEvents(IDictionary providerAbilities, Guid providerId) { - return providerAbilities != null && providerAbilities.ContainsKey(providerId) && - providerAbilities[providerId].Enabled && providerAbilities[providerId].UseEvents; + return providerAbilities != null && providerAbilities.TryGetValue(providerId, out var providerAbility) && + providerAbility.Enabled && providerAbility.UseEvents; } } diff --git a/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs b/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs deleted file mode 100644 index 4df2d25b1b..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/IntegrationEventHandlerBase.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Text.Json.Nodes; -using Bit.Core.AdminConsole.Models.Data.Integrations; -using Bit.Core.AdminConsole.Utilities; -using Bit.Core.Enums; -using Bit.Core.Models.Data; -using Bit.Core.Repositories; - -namespace Bit.Core.Services; - -public abstract class IntegrationEventHandlerBase( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationIntegrationConfigurationRepository configurationRepository) - : IEventMessageHandler -{ - public async Task HandleEventAsync(EventMessage eventMessage) - { - var organizationId = eventMessage.OrganizationId ?? Guid.Empty; - var configurations = await configurationRepository.GetConfigurationDetailsAsync( - organizationId, - GetIntegrationType(), - eventMessage.Type); - - foreach (var configuration in configurations) - { - var context = await BuildContextAsync(eventMessage, configuration.Template); - var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, context); - - await ProcessEventIntegrationAsync(configuration.MergedConfiguration, renderedTemplate); - } - } - - public async Task HandleManyEventsAsync(IEnumerable eventMessages) - { - foreach (var eventMessage in eventMessages) - { - await HandleEventAsync(eventMessage); - } - } - - private async Task BuildContextAsync(EventMessage eventMessage, string template) - { - var context = new IntegrationTemplateContext(eventMessage); - - if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue) - { - context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value); - } - - if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue) - { - context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value); - } - - if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue) - { - context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value); - } - - return context; - } - - protected abstract IntegrationType GetIntegrationType(); - - protected abstract Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate); -} diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 26ff421328..41e4f2f618 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -1,4 +1,6 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; @@ -12,9 +14,9 @@ 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.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; @@ -30,9 +32,6 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Settings; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using Stripe; @@ -44,23 +43,16 @@ public class OrganizationService : IOrganizationService { private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly ICollectionRepository _collectionRepository; - private readonly IUserRepository _userRepository; private readonly IGroupRepository _groupRepository; private readonly IMailService _mailService; private readonly IPushNotificationService _pushNotificationService; - private readonly IPushRegistrationService _pushRegistrationService; - private readonly IDeviceRepository _deviceRepository; - private readonly ILicensingService _licensingService; private readonly IEventService _eventService; private readonly IApplicationCacheService _applicationCacheService; private readonly IPaymentService _paymentService; private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; private readonly ISsoUserRepository _ssoUserRepository; - private readonly IReferenceEventService _referenceEventService; private readonly IGlobalSettings _globalSettings; - private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; private readonly ICurrentContext _currentContext; private readonly ILogger _logger; private readonly IProviderOrganizationRepository _providerOrganizationRepository; @@ -69,7 +61,6 @@ public class OrganizationService : IOrganizationService private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand; private readonly IProviderRepository _providerRepository; private readonly IFeatureService _featureService; - private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; private readonly IPricingClient _pricingClient; private readonly IPolicyRequirementQuery _policyRequirementQuery; @@ -78,23 +69,16 @@ public class OrganizationService : IOrganizationService public OrganizationService( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, - ICollectionRepository collectionRepository, - IUserRepository userRepository, IGroupRepository groupRepository, IMailService mailService, IPushNotificationService pushNotificationService, - IPushRegistrationService pushRegistrationService, - IDeviceRepository deviceRepository, - ILicensingService licensingService, IEventService eventService, IApplicationCacheService applicationCacheService, IPaymentService paymentService, IPolicyRepository policyRepository, IPolicyService policyService, ISsoUserRepository ssoUserRepository, - IReferenceEventService referenceEventService, IGlobalSettings globalSettings, - IOrganizationApiKeyRepository organizationApiKeyRepository, ICurrentContext currentContext, ILogger logger, IProviderOrganizationRepository providerOrganizationRepository, @@ -103,32 +87,24 @@ public class OrganizationService : IOrganizationService IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand, IProviderRepository providerRepository, IFeatureService featureService, - ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, IPricingClient pricingClient, IPolicyRequirementQuery policyRequirementQuery, ISendOrganizationInvitesCommand sendOrganizationInvitesCommand - ) + ) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; - _collectionRepository = collectionRepository; - _userRepository = userRepository; _groupRepository = groupRepository; _mailService = mailService; _pushNotificationService = pushNotificationService; - _pushRegistrationService = pushRegistrationService; - _deviceRepository = deviceRepository; - _licensingService = licensingService; _eventService = eventService; _applicationCacheService = applicationCacheService; _paymentService = paymentService; _policyRepository = policyRepository; _policyService = policyService; _ssoUserRepository = ssoUserRepository; - _referenceEventService = referenceEventService; _globalSettings = globalSettings; - _organizationApiKeyRepository = organizationApiKeyRepository; _currentContext = currentContext; _logger = logger; _providerOrganizationRepository = providerOrganizationRepository; @@ -137,7 +113,6 @@ public class OrganizationService : IOrganizationService _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; _providerRepository = providerRepository; _featureService = featureService; - _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; _pricingClient = pricingClient; _policyRequirementQuery = policyRequirementQuery; @@ -160,11 +135,6 @@ public class OrganizationService : IOrganizationService } await _paymentService.CancelSubscriptionAsync(organization, eop); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.CancelSubscription, organization, _currentContext) - { - EndOfPeriod = endOfPeriod, - }); } public async Task ReinstateSubscriptionAsync(Guid organizationId) @@ -176,8 +146,6 @@ public class OrganizationService : IOrganizationService } await _paymentService.ReinstateSubscriptionAsync(organization); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.ReinstateSubscription, organization, _currentContext)); } public async Task AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb) @@ -197,13 +165,6 @@ public class OrganizationService : IOrganizationService var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, organization, storageAdjustmentGb, plan.PasswordManager.StripeStoragePlanId); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.AdjustStorage, organization, _currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Storage = storageAdjustmentGb, - }); await ReplaceAndUpdateCacheAsync(organization); return secret; } @@ -226,6 +187,7 @@ public class OrganizationService : IOrganizationService { await AdjustSeatsAsync(organization, seatAdjustment); } + if (maxAutoscaleSeats != organization.MaxAutoscaleSeats) { await UpdateAutoscalingAsync(organization, maxAutoscaleSeats); @@ -234,7 +196,6 @@ public class OrganizationService : IOrganizationService private async Task UpdateAutoscalingAsync(Organization organization, int? maxAutoscaleSeats) { - if (maxAutoscaleSeats.HasValue && organization.Seats.HasValue && maxAutoscaleSeats.Value < organization.Seats.Value) @@ -256,7 +217,8 @@ public class OrganizationService : IOrganizationService if (plan.PasswordManager.MaxSeats.HasValue && maxAutoscaleSeats.HasValue && maxAutoscaleSeats > plan.PasswordManager.MaxSeats) { - throw new BadRequestException(string.Concat($"Your plan has a seat limit of {plan.PasswordManager.MaxSeats}, ", + throw new BadRequestException(string.Concat( + $"Your plan has a seat limit of {plan.PasswordManager.MaxSeats}, ", $"but you have specified a max autoscale count of {maxAutoscaleSeats}.", "Reduce your max autoscale seat count.")); } @@ -277,7 +239,8 @@ public class OrganizationService : IOrganizationService return await AdjustSeatsAsync(organization, seatAdjustment); } - private async Task AdjustSeatsAsync(Organization organization, int seatAdjustment, IEnumerable ownerEmails = null) + private async Task AdjustSeatsAsync(Organization organization, int seatAdjustment, + IEnumerable ownerEmails = null) { if (organization.Seats == null) { @@ -313,19 +276,30 @@ public class OrganizationService : IOrganizationService } var additionalSeats = newSeatTotal - plan.PasswordManager.BaseSeats; - if (plan.PasswordManager.MaxAdditionalSeats.HasValue && additionalSeats > plan.PasswordManager.MaxAdditionalSeats.Value) + if (plan.PasswordManager.MaxAdditionalSeats.HasValue && + additionalSeats > plan.PasswordManager.MaxAdditionalSeats.Value) { throw new BadRequestException($"Organization plan allows a maximum of " + - $"{plan.PasswordManager.MaxAdditionalSeats.Value} additional seats."); + $"{plan.PasswordManager.MaxAdditionalSeats.Value} additional seats."); } if (!organization.Seats.HasValue || organization.Seats.Value > newSeatTotal) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - if (occupiedSeats > newSeatTotal) + var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + + if (seatCounts.Total > newSeatTotal) { - throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " + - $"Your new plan only has ({newSeatTotal}) seats. Remove some users."); + if (organization.UseAdminSponsoredFamilies || seatCounts.Sponsored > 0) + { + throw new BadRequestException( + $"Your organization has {seatCounts.Users} members and {seatCounts.Sponsored} sponsored families. " + + $"To decrease the seat count below {seatCounts.Total}, you must remove members or sponsorships."); + } + else + { + throw new BadRequestException($"Your organization currently has {seatCounts.Total} seats filled. " + + $"Your new plan only has ({newSeatTotal}) seats. Remove some users."); + } } } @@ -334,19 +308,18 @@ public class OrganizationService : IOrganizationService throw new BadRequestException("You cannot have more Secrets Manager seats than Password Manager seats."); } + _logger.LogInformation("{Method}: Invoking _paymentService.AdjustSeatsAsync with {AdditionalSeats} additional seats for Organization ({OrganizationID})", + nameof(AdjustSeatsAsync), additionalSeats, organization.Id); + var paymentIntentClientSecret = await _paymentService.AdjustSeatsAsync(organization, plan, additionalSeats); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, _currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Seats = newSeatTotal, - PreviousSeats = organization.Seats - }); organization.Seats = (short?)newSeatTotal; + + _logger.LogInformation("{Method}: Invoking _organizationRepository.ReplaceAsync with {Seats} seats for Organization ({OrganizationID})", nameof(AdjustSeatsAsync), organization.Seats, organization.Id); ; + await ReplaceAndUpdateCacheAsync(organization); - if (organization.Seats.HasValue && organization.MaxAutoscaleSeats.HasValue && organization.Seats == organization.MaxAutoscaleSeats) + if (organization.Seats.HasValue && organization.MaxAutoscaleSeats.HasValue && + organization.Seats == organization.MaxAutoscaleSeats) { try { @@ -355,7 +328,9 @@ public class OrganizationService : IOrganizationService ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner)).Select(u => u.Email).Distinct(); } - await _mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSeats.Value, ownerEmails); + + await _mailService.SendOrganizationMaxSeatLimitReachedEmailAsync(organization, + organization.MaxAutoscaleSeats.Value, ownerEmails); } catch (Exception e) { @@ -389,7 +364,7 @@ public class OrganizationService : IOrganizationService } var bankAccount = customer.Sources - .FirstOrDefault(s => s is BankAccount && ((BankAccount)s).Status != "verified") as BankAccount; + .FirstOrDefault(s => s is BankAccount && ((BankAccount)s).Status != "verified") as BankAccount; if (bankAccount == null) { throw new GatewayException("Cannot find an unverified bank account."); @@ -410,185 +385,6 @@ public class OrganizationService : IOrganizationService } } - private async Task ValidateSignUpPoliciesAsync(Guid ownerId) - { - var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); - if (anySingleOrgPolicies) - { - throw new BadRequestException("You may not create an organization. You belong to an organization " + - "which has a policy that prohibits you from being a member of any other organization."); - } - } - - /// - /// Create a new organization on a self-hosted instance - /// - public async Task<(Organization organization, OrganizationUser organizationUser)> SignUpAsync( - OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey, - string privateKey) - { - if (license.LicenseType != LicenseType.Organization) - { - throw new BadRequestException("Premium licenses cannot be applied to an organization. " + - "Upload this license from your personal account settings page."); - } - - var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); - var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception); - - if (!canUse) - { - throw new BadRequestException(exception); - } - - var enabledOrgs = await _organizationRepository.GetManyByEnabledAsync(); - if (enabledOrgs.Any(o => string.Equals(o.LicenseKey, license.LicenseKey))) - { - throw new BadRequestException("License is already in use by another organization."); - } - - await ValidateSignUpPoliciesAsync(owner.Id); - - var organization = new Organization - { - Name = license.Name, - BillingEmail = license.BillingEmail, - BusinessName = license.BusinessName, - PlanType = license.PlanType, - Seats = license.Seats, - MaxCollections = license.MaxCollections, - MaxStorageGb = _globalSettings.SelfHosted ? 10240 : license.MaxStorageGb, // 10 TB - UsePolicies = license.UsePolicies, - UseSso = license.UseSso, - UseKeyConnector = license.UseKeyConnector, - UseScim = license.UseScim, - UseGroups = license.UseGroups, - UseDirectory = license.UseDirectory, - UseEvents = license.UseEvents, - UseTotp = license.UseTotp, - Use2fa = license.Use2fa, - UseApi = license.UseApi, - UseResetPassword = license.UseResetPassword, - Plan = license.Plan, - SelfHost = license.SelfHost, - UsersGetPremium = license.UsersGetPremium, - UseCustomPermissions = license.UseCustomPermissions, - Gateway = null, - GatewayCustomerId = null, - GatewaySubscriptionId = null, - ReferenceData = owner.ReferenceData, - Enabled = license.Enabled, - ExpirationDate = license.Expires, - LicenseKey = license.LicenseKey, - PublicKey = publicKey, - PrivateKey = privateKey, - CreationDate = DateTime.UtcNow, - RevisionDate = DateTime.UtcNow, - Status = OrganizationStatusType.Created, - UsePasswordManager = license.UsePasswordManager, - UseSecretsManager = license.UseSecretsManager, - SmSeats = license.SmSeats, - SmServiceAccounts = license.SmServiceAccounts, - UseRiskInsights = license.UseRiskInsights, - UseOrganizationDomains = license.UseOrganizationDomains, - UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies, - }; - - var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); - - var dir = $"{_globalSettings.LicenseDirectory}/organization"; - Directory.CreateDirectory(dir); - await using var fs = new FileStream(Path.Combine(dir, $"{organization.Id}.json"), FileMode.Create); - await JsonSerializer.SerializeAsync(fs, license, JsonHelpers.Indented); - return (result.organization, result.organizationUser); - } - - /// - /// Private helper method to create a new organization. - /// This is common code used by both the cloud and self-hosted methods. - /// - private async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignUpAsync(Organization organization, - Guid ownerId, string ownerKey, string collectionName, bool withPayment) - { - try - { - await _organizationRepository.CreateAsync(organization); - await _organizationApiKeyRepository.CreateAsync(new OrganizationApiKey - { - OrganizationId = organization.Id, - ApiKey = CoreHelpers.SecureRandomString(30), - Type = OrganizationApiKeyType.Default, - RevisionDate = DateTime.UtcNow, - }); - await _applicationCacheService.UpsertOrganizationAbilityAsync(organization); - - // ownerId == default if the org is created by a provider - in this case it's created without an - // owner and the first owner is immediately invited afterwards - OrganizationUser orgUser = null; - if (ownerId != default) - { - orgUser = new OrganizationUser - { - OrganizationId = organization.Id, - UserId = ownerId, - Key = ownerKey, - AccessSecretsManager = organization.UseSecretsManager, - Type = OrganizationUserType.Owner, - Status = OrganizationUserStatusType.Confirmed, - CreationDate = organization.CreationDate, - RevisionDate = organization.CreationDate - }; - orgUser.SetNewId(); - - await _organizationUserRepository.CreateAsync(orgUser); - - var devices = await GetUserDeviceIdsAsync(orgUser.UserId.Value); - await _pushRegistrationService.AddUserRegistrationOrganizationAsync(devices, - organization.Id.ToString()); - await _pushNotificationService.PushSyncOrgKeysAsync(ownerId); - } - - Collection defaultCollection = null; - if (!string.IsNullOrWhiteSpace(collectionName)) - { - defaultCollection = new Collection - { - Name = collectionName, - OrganizationId = organization.Id, - CreationDate = organization.CreationDate, - RevisionDate = organization.CreationDate - }; - - // Give the owner Can Manage access over the default collection - List defaultOwnerAccess = null; - if (orgUser != null) - { - defaultOwnerAccess = - [new CollectionAccessSelection { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }]; - } - - await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); - } - - return (organization, orgUser, defaultCollection); - } - catch - { - if (withPayment) - { - await _paymentService.CancelAndRecoverChargesAsync(organization); - } - - if (organization.Id != default(Guid)) - { - await _organizationRepository.DeleteAsync(organization); - await _applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); - } - - throw; - } - } - public async Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate) { var org = await GetOrgById(organizationId); @@ -600,7 +396,8 @@ public class OrganizationService : IOrganizationService } } - public async Task UpdateAsync(Organization organization, bool updateBilling = false, EventType eventType = EventType.Organization_Updated) + public async Task UpdateAsync(Organization organization, bool updateBilling = false, + EventType eventType = EventType.Organization_Updated) { if (organization.Id == default(Guid)) { @@ -621,11 +418,12 @@ public class OrganizationService : IOrganizationService if (updateBilling && !string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) { var customerService = new CustomerService(); - await customerService.UpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions - { - Email = organization.BillingEmail, - Description = organization.DisplayBusinessName() - }); + await customerService.UpdateAsync(organization.GatewayCustomerId, + new CustomerUpdateOptions + { + Email = organization.BillingEmail, + Description = organization.DisplayBusinessName() + }); } if (eventType == EventType.Organization_CollectionManagement_Updated) @@ -647,12 +445,12 @@ public class OrganizationService : IOrganizationService } var providers = organization.GetTwoFactorProviders(); - if (!providers?.ContainsKey(type) ?? true) + if (providers is null || !providers.TryGetValue(type, out var provider)) { return; } - providers[type].Enabled = true; + provider.Enabled = true; organization.SetTwoFactorProviders(providers); await UpdateAsync(organization); } @@ -675,7 +473,8 @@ public class OrganizationService : IOrganizationService await UpdateAsync(organization); } - public async Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, + public async Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, + EventSystemUser? systemUser, OrganizationUserInvite invite, string externalId) { // Ideally OrganizationUserInvite should represent a single user so that this doesn't have to be a runtime check @@ -688,7 +487,8 @@ public class OrganizationService : IOrganizationService var invalidAssociations = invite.Collections?.Where(cas => cas.Manage && (cas.ReadOnly || cas.HidePasswords)); if (invalidAssociations?.Any() ?? false) { - throw new BadRequestException("The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."); + throw new BadRequestException( + "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true."); } var results = await InviteUsersAsync(organizationId, invitingUserId, systemUser, @@ -699,6 +499,7 @@ public class OrganizationService : IOrganizationService { throw new BadRequestException("This user has already been invited."); } + return result; } @@ -710,7 +511,8 @@ public class OrganizationService : IOrganizationService /// The system user which is sending the invite. Only used when inviting via SCIM; null if using a client app or Public API /// Details about the users being invited /// - public async Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, + public async Task> InviteUsersAsync(Guid organizationId, Guid? invitingUserId, + EventSystemUser? systemUser, IEnumerable<(OrganizationUserInvite invite, string externalId)> invites) { var inviteTypes = new HashSet(invites.Where(i => i.invite.Type.HasValue) @@ -722,7 +524,8 @@ public class OrganizationService : IOrganizationService { foreach (var (invite, _) in invites) { - await ValidateOrganizationUserUpdatePermissions(organizationId, invite.Type.Value, null, invite.Permissions); + await ValidateOrganizationUserUpdatePermissions(organizationId, invite.Type.Value, null, + invite.Permissions); await ValidateOrganizationCustomPermissionsEnabledAsync(organizationId, invite.Type.Value); } } @@ -732,7 +535,8 @@ public class OrganizationService : IOrganizationService if (systemUser.HasValue) { // Log SCIM event - await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.Item1, e.Item2, systemUser.Value, e.Item3))); + await _eventService.LogOrganizationUserEventsAsync(events.Select(e => + (e.Item1, e.Item2, systemUser.Value, e.Item3))); } else { @@ -743,8 +547,10 @@ public class OrganizationService : IOrganizationService return organizationUsers; } - private async Task<(List organizationUsers, List<(OrganizationUser, EventType, DateTime?)> events)> SaveUsersSendInvitesAsync(Guid organizationId, - IEnumerable<(OrganizationUserInvite invite, string externalId)> invites) + private async + Task<(List organizationUsers, List<(OrganizationUser, EventType, DateTime?)> events)> + SaveUsersSendInvitesAsync(Guid organizationId, + IEnumerable<(OrganizationUserInvite invite, string externalId)> invites) { var organization = await GetOrgById(organizationId); var initialSeatCount = organization.Seats; @@ -754,15 +560,16 @@ public class OrganizationService : IOrganizationService } var existingEmails = new HashSet(await _organizationUserRepository.SelectKnownEmailsAsync( - organizationId, invites.SelectMany(i => i.invite.Emails), false), StringComparer.InvariantCultureIgnoreCase); + organizationId, invites.SelectMany(i => i.invite.Emails), false), + StringComparer.InvariantCultureIgnoreCase); // Seat autoscaling var initialSmSeatCount = organization.SmSeats; var newSeatsRequired = 0; if (organization.Seats.HasValue) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - var availableSeats = organization.Seats.Value - occupiedSeats; + var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var availableSeats = organization.Seats.Value - seatCounts.Total; newSeatsRequired = invites.Sum(i => i.invite.Emails.Count()) - existingEmails.Count() - availableSeats; } @@ -782,7 +589,8 @@ public class OrganizationService : IOrganizationService .SelectMany(i => i.invite.Emails) .Count(email => !existingEmails.Contains(email)); - var additionalSmSeatsRequired = await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, inviteWithSmAccessCount); + var additionalSmSeatsRequired = + await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, inviteWithSmAccessCount); if (additionalSmSeatsRequired > 0) { var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); @@ -791,7 +599,9 @@ public class OrganizationService : IOrganizationService } var invitedAreAllOwners = invites.All(i => i.invite.Type == OrganizationUserType.Owner); - if (!invitedAreAllOwners && !await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new Guid[] { }, includeProvider: true)) + if (!invitedAreAllOwners && + !await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new Guid[] { }, + includeProvider: true)) { throw new BadRequestException("Organization must have at least one confirmed owner."); } @@ -893,12 +703,6 @@ public class OrganizationService : IOrganizationService } await SendInvitesAsync(allOrgUsers, organization); - - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.InvitedUsers, organization, _currentContext) - { - Users = orgUserInvitedCount - }); } catch (Exception e) { @@ -920,7 +724,8 @@ public class OrganizationService : IOrganizationService await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(smSubscriptionUpdateRevert); } - if (initialSeatCount.HasValue && currentOrganization.Seats.HasValue && currentOrganization.Seats.Value != initialSeatCount.Value) + if (initialSeatCount.HasValue && currentOrganization.Seats.HasValue && + currentOrganization.Seats.Value != initialSeatCount.Value) { await AdjustSeatsAsync(organization, initialSeatCount.Value - currentOrganization.Seats.Value); } @@ -936,10 +741,13 @@ public class OrganizationService : IOrganizationService return (allOrgUsers, events); } - public async Task>> ResendInvitesAsync(Guid organizationId, Guid? invitingUserId, + 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>(); @@ -958,18 +766,6 @@ public class OrganizationService : IOrganizationService return result; } - public async Task ResendInviteAsync(Guid organizationId, Guid? invitingUserId, Guid organizationUserId, bool initOrganization = false) - { - var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - if (orgUser == null || orgUser.OrganizationId != organizationId || - orgUser.Status != OrganizationUserStatusType.Invited) - { - throw new BadRequestException("User invalid."); - } - - var org = await GetOrgById(orgUser.OrganizationId); - await SendInviteAsync(orgUser, org, initOrganization); - } private async Task SendInvitesAsync(IEnumerable orgUsers, Organization organization) => await _sendOrganizationInvitesCommand.SendInvitesAsync(new SendInvitesRequest(orgUsers, organization)); @@ -1045,7 +841,9 @@ public class OrganizationService : IOrganizationService IEnumerable ownerEmails; if (providerOrg != null) { - ownerEmails = (await _providerUserRepository.GetManyDetailsByProviderAsync(providerOrg.ProviderId, ProviderUserStatusType.Confirmed)) + ownerEmails = + (await _providerUserRepository.GetManyDetailsByProviderAsync(providerOrg.ProviderId, + ProviderUserStatusType.Confirmed)) .Select(u => u.Email).Distinct(); } else @@ -1053,6 +851,7 @@ public class OrganizationService : IOrganizationService ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner)).Select(u => u.Email).Distinct(); } + var initialSeatCount = organization.Seats.Value; await AdjustSeatsAsync(organization, seatsToAdd, ownerEmails); @@ -1067,8 +866,8 @@ public class OrganizationService : IOrganizationService } - - public async Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, Guid? callingUserId) + public async Task UpdateUserResetPasswordEnrollmentAsync(Guid organizationId, Guid userId, string resetPasswordKey, + Guid? callingUserId) { // Org User must be the same as the calling user and the organization ID associated with the user must match passed org ID var orgUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId); @@ -1096,30 +895,35 @@ public class OrganizationService : IOrganizationService // Block the user from withdrawal if auto enrollment is enabled if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) { - var resetPasswordPolicyRequirement = await _policyRequirementQuery.GetAsync(userId); + var resetPasswordPolicyRequirement = + await _policyRequirementQuery.GetAsync(userId); if (resetPasswordKey == null && resetPasswordPolicyRequirement.AutoEnrollEnabled(organizationId)) { - throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from account recovery."); + throw new BadRequestException( + "Due to an Enterprise Policy, you are not allowed to withdraw from account recovery."); } - } else { if (resetPasswordKey == null && resetPasswordPolicy.Data != null) { - var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase); + var data = JsonSerializer.Deserialize(resetPasswordPolicy.Data, + JsonHelpers.IgnoreCase); if (data?.AutoEnrollEnabled ?? false) { - throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to withdraw from account recovery."); + throw new BadRequestException( + "Due to an Enterprise Policy, you are not allowed to withdraw from account recovery."); } } } orgUser.ResetPasswordKey = resetPasswordKey; await _organizationUserRepository.ReplaceAsync(orgUser); - await _eventService.LogOrganizationUserEventAsync(orgUser, resetPasswordKey != null ? - EventType.OrganizationUser_ResetPassword_Enroll : EventType.OrganizationUser_ResetPassword_Withdraw); + await _eventService.LogOrganizationUserEventAsync(orgUser, + resetPasswordKey != null + ? EventType.OrganizationUser_ResetPassword_Enroll + : EventType.OrganizationUser_ResetPassword_Withdraw); } public async Task ImportAsync(Guid organizationId, @@ -1156,15 +960,16 @@ public class OrganizationService : IOrganizationService var existingUsersDict = existingExternalUsers.ToDictionary(u => u.ExternalId); var removeUsersSet = new HashSet(removeUserExternalIds) .Except(newUsersSet) - .Where(u => existingUsersDict.ContainsKey(u) && existingUsersDict[u].Type != OrganizationUserType.Owner) + .Where(u => existingUsersDict.TryGetValue(u, out var existingUser) && + existingUser.Type != OrganizationUserType.Owner) .Select(u => existingUsersDict[u]); await _organizationUserRepository.DeleteManyAsync(removeUsersSet.Select(u => u.Id)); events.AddRange(removeUsersSet.Select(u => ( - u, - EventType.OrganizationUser_Removed, - (DateTime?)DateTime.UtcNow - )) + u, + EventType.OrganizationUser_Removed, + (DateTime?)DateTime.UtcNow + )) ); } @@ -1177,10 +982,10 @@ public class OrganizationService : IOrganizationService existingExternalUsersIdDict.ContainsKey(u.ExternalId)); await _organizationUserRepository.DeleteManyAsync(usersToDelete.Select(u => u.Id)); events.AddRange(usersToDelete.Select(u => ( - u, - EventType.OrganizationUser_Removed, - (DateTime?)DateTime.UtcNow - )) + u, + EventType.OrganizationUser_Removed, + (DateTime?)DateTime.UtcNow + )) ); foreach (var deletedUser in usersToDelete) { @@ -1208,6 +1013,7 @@ public class OrganizationService : IOrganizationService existingExternalUsersIdDict.Add(orgUser.ExternalId, orgUser.Id); } } + await _organizationUserRepository.UpsertManyAsync(usersToUpsert); // Add new users @@ -1218,8 +1024,9 @@ public class OrganizationService : IOrganizationService var enoughSeatsAvailable = true; if (organization.Seats.HasValue) { - var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - seatsAvailable = organization.Seats.Value - occupiedSeats; + var seatCounts = + await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + seatsAvailable = organization.Seats.Value - seatCounts.Total; enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count; } @@ -1251,7 +1058,8 @@ public class OrganizationService : IOrganizationService } } - var invitedUsers = await InviteUsersAsync(organizationId, invitingUserId: null, systemUser: eventSystemUser, userInvites); + var invitedUsers = await InviteUsersAsync(organizationId, invitingUserId: null, systemUser: eventSystemUser, + userInvites); foreach (var invitedUser in invitedUsers) { existingExternalUsersIdDict.Add(invitedUser.ExternalId, invitedUser.Id); @@ -1288,7 +1096,8 @@ public class OrganizationService : IOrganizationService } await _eventService.LogGroupEventsAsync( - savedGroups.Select(g => (g, EventType.Group_Created, (EventSystemUser?)eventSystemUser, (DateTime?)DateTime.UtcNow))); + savedGroups.Select(g => (g, EventType.Group_Created, (EventSystemUser?)eventSystemUser, + (DateTime?)DateTime.UtcNow))); var updateGroups = existingExternalGroups .Where(g => groupsDict.ContainsKey(g.ExternalId)) @@ -1315,17 +1124,15 @@ public class OrganizationService : IOrganizationService await UpdateUsersAsync(group, groupsDict[group.ExternalId].ExternalUserIds, existingExternalUsersIdDict, existingGroupUsers.ContainsKey(group.Id) ? existingGroupUsers[group.Id] : null); - } await _eventService.LogGroupEventsAsync( - updateGroups.Select(g => (g, EventType.Group_Updated, (EventSystemUser?)eventSystemUser, (DateTime?)DateTime.UtcNow))); + updateGroups.Select(g => (g, EventType.Group_Updated, (EventSystemUser?)eventSystemUser, + (DateTime?)DateTime.UtcNow))); } } await _eventService.LogOrganizationUserEventsAsync(events.Select(e => (e.ou, e.e, eventSystemUser, e.d))); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.DirectorySynced, organization, _currentContext)); } public async Task DeleteSsoUserAsync(Guid userId, Guid? organizationId) @@ -1333,10 +1140,12 @@ public class OrganizationService : IOrganizationService await _ssoUserRepository.DeleteAsync(userId, organizationId); if (organizationId.HasValue) { - var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId.Value, userId); + var organizationUser = + await _organizationUserRepository.GetByOrganizationAsync(organizationId.Value, userId); if (organizationUser != null) { - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_UnlinkedSso); + await _eventService.LogOrganizationUserEventAsync(organizationUser, + EventType.OrganizationUser_UnlinkedSso); } } } @@ -1354,22 +1163,22 @@ public class OrganizationService : IOrganizationService await _groupRepository.UpdateUsersAsync(group.Id, users); } - private async Task> GetUserDeviceIdsAsync(Guid userId) - { - var devices = await _deviceRepository.GetManyByUserIdAsync(userId); - return devices - .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) - .Select(d => d.Id.ToString()); - } - public async Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null) { - await _organizationRepository.ReplaceAsync(org); - await _applicationCacheService.UpsertOrganizationAbilityAsync(org); - - if (orgEvent.HasValue) + try { - await _eventService.LogOrganizationEventAsync(org, orgEvent.Value); + await _organizationRepository.ReplaceAsync(org); + await _applicationCacheService.UpsertOrganizationAbilityAsync(org); + + if (orgEvent.HasValue) + { + await _eventService.LogOrganizationEventAsync(org, orgEvent.Value); + } + } + catch (Exception exception) + { + _logger.LogError(exception, "An error occurred while calling {Method} for Organization ({OrganizationID})", nameof(ReplaceAndUpdateCacheAsync), org.Id); + throw; } } @@ -1458,7 +1267,7 @@ public class OrganizationService : IOrganizationService } if ((plan.ProductTier == ProductTierType.TeamsStarter && - upgrade.AdditionalSmSeats.GetValueOrDefault() > plan.PasswordManager.BaseSeats) || + upgrade.AdditionalSmSeats.GetValueOrDefault() > plan.PasswordManager.BaseSeats) || (plan.ProductTier != ProductTierType.TeamsStarter && upgrade.AdditionalSmSeats.GetValueOrDefault() > upgrade.AdditionalSeats)) { @@ -1481,7 +1290,8 @@ public class OrganizationService : IOrganizationService } } - public async Task ValidateOrganizationUserUpdatePermissions(Guid organizationId, OrganizationUserType newType, OrganizationUserType? oldType, Permissions permissions) + public async Task ValidateOrganizationUserUpdatePermissions(Guid organizationId, OrganizationUserType newType, + OrganizationUserType? oldType, Permissions permissions) { if (await _currentContext.OrganizationOwner(organizationId)) { @@ -1508,13 +1318,15 @@ public class OrganizationService : IOrganizationService throw new BadRequestException("Custom users can not manage Admins or Owners."); } - if (newType == OrganizationUserType.Custom && !await ValidateCustomPermissionsGrant(organizationId, permissions)) + if (newType == OrganizationUserType.Custom && + !await ValidateCustomPermissionsGrant(organizationId, permissions)) { throw new BadRequestException("Custom users can only grant the same custom permissions that they have."); } } - public async Task ValidateOrganizationCustomPermissionsEnabledAsync(Guid organizationId, OrganizationUserType newType) + public async Task ValidateOrganizationCustomPermissionsEnabledAsync(Guid organizationId, + OrganizationUserType newType) { if (newType != OrganizationUserType.Custom) { @@ -1529,7 +1341,8 @@ public class OrganizationService : IOrganizationService if (!organization.UseCustomPermissions) { - throw new BadRequestException("To enable custom permissions the organization must be on an Enterprise plan."); + throw new BadRequestException( + "To enable custom permissions the organization must be on an Enterprise plan."); } } @@ -1609,185 +1422,6 @@ public class OrganizationService : IOrganizationService return true; } - public async Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId) - { - if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId.Value) - { - throw new BadRequestException("You cannot revoke yourself."); - } - - if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue && - !await _currentContext.OrganizationOwner(organizationUser.OrganizationId)) - { - throw new BadRequestException("Only owners can revoke other owners."); - } - - await RepositoryRevokeUserAsync(organizationUser); - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); - - if (organizationUser.UserId.HasValue) - { - await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); - } - } - - public async Task RevokeUserAsync(OrganizationUser organizationUser, - EventSystemUser systemUser) - { - await RepositoryRevokeUserAsync(organizationUser); - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked, systemUser); - - if (organizationUser.UserId.HasValue) - { - await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); - } - } - - private async Task RepositoryRevokeUserAsync(OrganizationUser organizationUser) - { - if (organizationUser.Status == OrganizationUserStatusType.Revoked) - { - throw new BadRequestException("Already revoked."); - } - - if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationUser.OrganizationId, new[] { organizationUser.Id }, includeProvider: true)) - { - throw new BadRequestException("Organization must have at least one confirmed owner."); - } - - await _organizationUserRepository.RevokeAsync(organizationUser.Id); - organizationUser.Status = OrganizationUserStatusType.Revoked; - } - - public async Task>> RevokeUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? revokingUserId) - { - var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUserIds); - var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) - .ToList(); - - if (!filteredUsers.Any()) - { - throw new BadRequestException("Users invalid."); - } - - if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds)) - { - throw new BadRequestException("Organization must have at least one confirmed owner."); - } - - var deletingUserIsOwner = false; - if (revokingUserId.HasValue) - { - deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId); - } - - var result = new List>(); - - foreach (var organizationUser in filteredUsers) - { - try - { - if (organizationUser.Status == OrganizationUserStatusType.Revoked) - { - throw new BadRequestException("Already revoked."); - } - - if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId) - { - throw new BadRequestException("You cannot revoke yourself."); - } - - if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue && !deletingUserIsOwner) - { - throw new BadRequestException("Only owners can revoke other owners."); - } - - await _organizationUserRepository.RevokeAsync(organizationUser.Id); - organizationUser.Status = OrganizationUserStatusType.Revoked; - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked); - if (organizationUser.UserId.HasValue) - { - await _pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value); - } - - result.Add(Tuple.Create(organizationUser, "")); - } - catch (BadRequestException e) - { - result.Add(Tuple.Create(organizationUser, e.Message)); - } - } - - return result; - } - - private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, bool userHasTwoFactorEnabled) - { - // An invited OrganizationUser isn't linked with a user account yet, so these checks are irrelevant - // The user will be subject to the same checks when they try to accept the invite - if (GetPriorActiveOrganizationUserStatusType(orgUser) == OrganizationUserStatusType.Invited) - { - return; - } - - var userId = orgUser.UserId.Value; - - // Enforce Single Organization Policy of organization user is being restored to - var allOrgUsers = await _organizationUserRepository.GetManyByUserAsync(userId); - var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId); - var singleOrgPoliciesApplyingToRevokedUsers = await _policyService.GetPoliciesApplicableToUserAsync(userId, - PolicyType.SingleOrg, OrganizationUserStatusType.Revoked); - var singleOrgPolicyApplies = singleOrgPoliciesApplyingToRevokedUsers.Any(p => p.OrganizationId == orgUser.OrganizationId); - - var singleOrgCompliant = true; - var belongsToOtherOrgCompliant = true; - var twoFactorCompliant = true; - - if (hasOtherOrgs && singleOrgPolicyApplies) - { - singleOrgCompliant = false; - } - - // Enforce Single Organization Policy of other organizations user is a member of - var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId, - PolicyType.SingleOrg); - if (anySingleOrgPolicies) - { - belongsToOtherOrgCompliant = false; - } - - // Enforce Two Factor Authentication Policy of organization user is trying to join - if (!userHasTwoFactorEnabled) - { - var invitedTwoFactorPolicies = await _policyService.GetPoliciesApplicableToUserAsync(userId, - PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Revoked); - if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) - { - twoFactorCompliant = false; - } - } - - var user = await _userRepository.GetByIdAsync(userId); - - if (!singleOrgCompliant && !twoFactorCompliant) - { - throw new BadRequestException(user.Email + " is not compliant with the single organization and two-step login polciy"); - } - else if (!singleOrgCompliant) - { - throw new BadRequestException(user.Email + " is not compliant with the single organization policy"); - } - else if (!belongsToOtherOrgCompliant) - { - throw new BadRequestException(user.Email + " belongs to an organization that doesn't allow them to join multiple organizations"); - } - else if (!twoFactorCompliant) - { - throw new BadRequestException(user.Email + " is not compliant with the two-step login policy"); - } - } - public static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser) { // Determine status to revert back to @@ -1805,33 +1439,4 @@ public class OrganizationService : IOrganizationService return status; } - - public async Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted) - { - organization.Id = CoreHelpers.GenerateComb(); - organization.Enabled = false; - organization.Status = OrganizationStatusType.Pending; - - await SignUpAsync(organization, default, null, null, true); - - var ownerOrganizationUser = new OrganizationUser - { - OrganizationId = organization.Id, - UserId = null, - Email = ownerEmail, - Key = null, - Type = OrganizationUserType.Owner, - Status = OrganizationUserStatusType.Invited, - }; - await _organizationUserRepository.CreateAsync(ownerOrganizationUser); - - await SendInviteAsync(ownerOrganizationUser, organization, true); - await _eventService.LogOrganizationUserEventAsync(ownerOrganizationUser, EventType.OrganizationUser_Invited); - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.OrganizationCreatedByAdmin, organization, _currentContext) - { - EventRaisedByUser = userService.GetUserName(user), - SalesAssistedTrialStarted = salesAssistedTrialStarted, - }); - } } diff --git a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs index c3eb2272d0..a83eccc301 100644 --- a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs +++ b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs @@ -1,5 +1,10 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; @@ -16,21 +21,39 @@ public class PolicyService : IPolicyService private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPolicyRepository _policyRepository; private readonly GlobalSettings _globalSettings; + private readonly IFeatureService _featureService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public PolicyService( IApplicationCacheService applicationCacheService, IOrganizationUserRepository organizationUserRepository, IPolicyRepository policyRepository, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery) { _applicationCacheService = applicationCacheService; _organizationUserRepository = organizationUserRepository; _policyRepository = policyRepository; _globalSettings = globalSettings; + _featureService = featureService; + _policyRequirementQuery = policyRequirementQuery; } public async Task GetMasterPasswordPolicyForUserAsync(User user) { + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + var masterPaswordPolicy = (await _policyRequirementQuery.GetAsync(user.Id)); + + if (!masterPaswordPolicy.Enabled) + { + return null; + } + + return masterPaswordPolicy.EnforcedOptions; + } + var policies = (await _policyRepository.GetManyByUserIdAsync(user.Id)) .Where(p => p.Type == PolicyType.MasterPassword && p.Enabled) .ToList(); @@ -48,6 +71,7 @@ public class PolicyService : IPolicyService } return enforcedOptions; + } public async Task> GetPoliciesApplicableToUserAsync(Guid userId, PolicyType policyType, OrganizationUserStatusType minStatus = OrganizationUserStatusType.Accepted) @@ -68,7 +92,7 @@ public class PolicyService : IPolicyService var excludedUserTypes = GetUserTypesExcludedFromPolicy(policyType); var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); return organizationUserPolicyDetails.Where(o => - (!orgAbilities.ContainsKey(o.OrganizationId) || orgAbilities[o.OrganizationId].UsePolicies) && + (!orgAbilities.TryGetValue(o.OrganizationId, out var orgAbility) || orgAbility.UsePolicies) && o.PolicyEnabled && !excludedUserTypes.Contains(o.OrganizationUserType) && o.OrganizationUserStatus >= minStatus && diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs deleted file mode 100644 index 74833f38a0..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventListenerService.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.Text; -using System.Text.Json; -using Bit.Core.Models.Data; -using Bit.Core.Settings; -using Microsoft.Extensions.Logging; -using RabbitMQ.Client; -using RabbitMQ.Client.Events; - -namespace Bit.Core.Services; - -public class RabbitMqEventListenerService : EventLoggingListenerService -{ - private IChannel _channel; - private IConnection _connection; - private readonly string _exchangeName; - private readonly ConnectionFactory _factory; - private readonly ILogger _logger; - private readonly string _queueName; - - public RabbitMqEventListenerService( - IEventMessageHandler handler, - ILogger logger, - GlobalSettings globalSettings, - string queueName) : base(handler) - { - _factory = new ConnectionFactory - { - HostName = globalSettings.EventLogging.RabbitMq.HostName, - UserName = globalSettings.EventLogging.RabbitMq.Username, - Password = globalSettings.EventLogging.RabbitMq.Password - }; - _exchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName; - _logger = logger; - _queueName = queueName; - } - - public override async Task StartAsync(CancellationToken cancellationToken) - { - _connection = await _factory.CreateConnectionAsync(cancellationToken); - _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); - - await _channel.ExchangeDeclareAsync(exchange: _exchangeName, - type: ExchangeType.Fanout, - durable: true, - cancellationToken: cancellationToken); - await _channel.QueueDeclareAsync(queue: _queueName, - durable: true, - exclusive: false, - autoDelete: false, - arguments: null, - cancellationToken: cancellationToken); - await _channel.QueueBindAsync(queue: _queueName, - exchange: _exchangeName, - routingKey: string.Empty, - cancellationToken: cancellationToken); - await base.StartAsync(cancellationToken); - } - - protected override async Task ExecuteAsync(CancellationToken cancellationToken) - { - var consumer = new AsyncEventingBasicConsumer(_channel); - consumer.ReceivedAsync += async (_, eventArgs) => - { - try - { - using var jsonDocument = JsonDocument.Parse(Encoding.UTF8.GetString(eventArgs.Body.Span)); - var root = jsonDocument.RootElement; - - if (root.ValueKind == JsonValueKind.Array) - { - var eventMessages = root.Deserialize>(); - await _handler.HandleManyEventsAsync(eventMessages); - } - else if (root.ValueKind == JsonValueKind.Object) - { - var eventMessage = root.Deserialize(); - await _handler.HandleEventAsync(eventMessage); - - } - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred while processing the message"); - } - }; - - await _channel.BasicConsumeAsync(_queueName, autoAck: true, consumer: consumer, cancellationToken: cancellationToken); - } - - public override async Task StopAsync(CancellationToken cancellationToken) - { - await _channel.CloseAsync(cancellationToken); - await _connection.CloseAsync(cancellationToken); - await base.StopAsync(cancellationToken); - } - - public override void Dispose() - { - _channel.Dispose(); - _connection.Dispose(); - base.Dispose(); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs deleted file mode 100644 index 05fcf71a92..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Text.Json; -using Bit.Core.Models.Data; -using Bit.Core.Settings; -using RabbitMQ.Client; - -namespace Bit.Core.Services; -public class RabbitMqEventWriteService : IEventWriteService, IAsyncDisposable -{ - private readonly ConnectionFactory _factory; - private readonly Lazy> _lazyConnection; - private readonly string _exchangeName; - - public RabbitMqEventWriteService(GlobalSettings globalSettings) - { - _factory = new ConnectionFactory - { - HostName = globalSettings.EventLogging.RabbitMq.HostName, - UserName = globalSettings.EventLogging.RabbitMq.Username, - Password = globalSettings.EventLogging.RabbitMq.Password - }; - _exchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName; - - _lazyConnection = new Lazy>(CreateConnectionAsync); - } - - public async Task CreateAsync(IEvent e) - { - var connection = await _lazyConnection.Value; - using var channel = await connection.CreateChannelAsync(); - - await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true); - - var body = JsonSerializer.SerializeToUtf8Bytes(e); - - await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: string.Empty, body: body); - } - - public async Task CreateManyAsync(IEnumerable events) - { - var connection = await _lazyConnection.Value; - using var channel = await connection.CreateChannelAsync(); - await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true); - - var body = JsonSerializer.SerializeToUtf8Bytes(events); - - await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: string.Empty, body: body); - } - - public async ValueTask DisposeAsync() - { - if (_lazyConnection.IsValueCreated) - { - var connection = await _lazyConnection.Value; - await connection.DisposeAsync(); - } - } - - private async Task CreateConnectionAsync() - { - return await _factory.CreateConnectionAsync(); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs deleted file mode 100644 index 1d6910db95..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationListenerService.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System.Text; -using Bit.Core.Settings; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using RabbitMQ.Client; -using RabbitMQ.Client.Events; - -namespace Bit.Core.Services; - -public class RabbitMqIntegrationListenerService : BackgroundService -{ - private const string _deadLetterRoutingKey = "dead-letter"; - private IChannel _channel; - private IConnection _connection; - private readonly string _exchangeName; - private readonly string _queueName; - private readonly string _retryQueueName; - private readonly string _deadLetterQueueName; - private readonly string _routingKey; - private readonly string _retryRoutingKey; - private readonly int _maxRetries; - private readonly IIntegrationHandler _handler; - private readonly ConnectionFactory _factory; - private readonly ILogger _logger; - private readonly int _retryTiming; - - public RabbitMqIntegrationListenerService(IIntegrationHandler handler, - string routingKey, - string queueName, - string retryQueueName, - string deadLetterQueueName, - GlobalSettings globalSettings, - ILogger logger) - { - _handler = handler; - _routingKey = routingKey; - _retryRoutingKey = $"{_routingKey}-retry"; - _queueName = queueName; - _retryQueueName = retryQueueName; - _deadLetterQueueName = deadLetterQueueName; - _logger = logger; - _exchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName; - _maxRetries = globalSettings.EventLogging.RabbitMq.MaxRetries; - _retryTiming = globalSettings.EventLogging.RabbitMq.RetryTiming; - - _factory = new ConnectionFactory - { - HostName = globalSettings.EventLogging.RabbitMq.HostName, - UserName = globalSettings.EventLogging.RabbitMq.Username, - Password = globalSettings.EventLogging.RabbitMq.Password - }; - } - - public override async Task StartAsync(CancellationToken cancellationToken) - { - _connection = await _factory.CreateConnectionAsync(cancellationToken); - _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); - - await _channel.ExchangeDeclareAsync(exchange: _exchangeName, - type: ExchangeType.Direct, - durable: true, - cancellationToken: cancellationToken); - - // Declare main queue - await _channel.QueueDeclareAsync(queue: _queueName, - durable: true, - exclusive: false, - autoDelete: false, - arguments: null, - cancellationToken: cancellationToken); - await _channel.QueueBindAsync(queue: _queueName, - exchange: _exchangeName, - routingKey: _routingKey, - cancellationToken: cancellationToken); - - // Declare retry queue (Configurable TTL, dead-letters back to main queue) - await _channel.QueueDeclareAsync(queue: _retryQueueName, - durable: true, - exclusive: false, - autoDelete: false, - arguments: new Dictionary - { - { "x-dead-letter-exchange", _exchangeName }, - { "x-dead-letter-routing-key", _routingKey }, - { "x-message-ttl", _retryTiming } - }, - cancellationToken: cancellationToken); - await _channel.QueueBindAsync(queue: _retryQueueName, - exchange: _exchangeName, - routingKey: _retryRoutingKey, - cancellationToken: cancellationToken); - - // Declare dead letter queue - await _channel.QueueDeclareAsync(queue: _deadLetterQueueName, - durable: true, - exclusive: false, - autoDelete: false, - arguments: null, - cancellationToken: cancellationToken); - await _channel.QueueBindAsync(queue: _deadLetterQueueName, - exchange: _exchangeName, - routingKey: _deadLetterRoutingKey, - cancellationToken: cancellationToken); - - await base.StartAsync(cancellationToken); - } - - protected override async Task ExecuteAsync(CancellationToken cancellationToken) - { - var consumer = new AsyncEventingBasicConsumer(_channel); - consumer.ReceivedAsync += async (_, ea) => - { - var json = Encoding.UTF8.GetString(ea.Body.Span); - - try - { - var result = await _handler.HandleAsync(json); - var message = result.Message; - - if (result.Success) - { - // Successful integration send. Acknowledge message delivery and return - await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); - return; - } - - if (result.Retryable) - { - // Integration failed, but is retryable - apply delay and check max retries - message.ApplyRetry(result.DelayUntilDate); - - if (message.RetryCount < _maxRetries) - { - // Publish message to the retry queue. It will be re-published for retry after a delay - await _channel.BasicPublishAsync( - exchange: _exchangeName, - routingKey: _retryRoutingKey, - body: Encoding.UTF8.GetBytes(message.ToJson()), - cancellationToken: cancellationToken); - } - else - { - // Exceeded the max number of retries; fail and send to dead letter queue - await PublishToDeadLetterAsync(message.ToJson()); - _logger.LogWarning("Max retry attempts reached. Sent to DLQ."); - } - } - else - { - // Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries - await PublishToDeadLetterAsync(message.ToJson()); - _logger.LogWarning("Non-retryable failure. Sent to DLQ."); - } - - // Message has been sent to retry or dead letter queues. - // Acknowledge receipt so Rabbit knows it's been processed - await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); - } - catch (Exception ex) - { - // Unknown error occurred. Acknowledge so Rabbit doesn't keep attempting. Log the error - _logger.LogError(ex, "Unhandled error processing integration message."); - await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken); - } - }; - - await _channel.BasicConsumeAsync(queue: _queueName, autoAck: false, consumer: consumer, cancellationToken: cancellationToken); - } - - private async Task PublishToDeadLetterAsync(string json) - { - await _channel.BasicPublishAsync( - exchange: _exchangeName, - routingKey: _deadLetterRoutingKey, - body: Encoding.UTF8.GetBytes(json)); - } - - public override async Task StopAsync(CancellationToken cancellationToken) - { - await _channel.CloseAsync(cancellationToken); - await _connection.CloseAsync(cancellationToken); - await base.StopAsync(cancellationToken); - } - - public override void Dispose() - { - _channel.Dispose(); - _connection.Dispose(); - base.Dispose(); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationPublisher.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationPublisher.cs deleted file mode 100644 index 12801e3216..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/RabbitMqIntegrationPublisher.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Text; -using Bit.Core.AdminConsole.Models.Data.Integrations; -using Bit.Core.Enums; -using Bit.Core.Settings; -using RabbitMQ.Client; - -namespace Bit.Core.Services; - -public class RabbitMqIntegrationPublisher : IIntegrationPublisher, IAsyncDisposable -{ - private readonly ConnectionFactory _factory; - private readonly Lazy> _lazyConnection; - private readonly string _exchangeName; - - public RabbitMqIntegrationPublisher(GlobalSettings globalSettings) - { - _factory = new ConnectionFactory - { - HostName = globalSettings.EventLogging.RabbitMq.HostName, - UserName = globalSettings.EventLogging.RabbitMq.Username, - Password = globalSettings.EventLogging.RabbitMq.Password - }; - _exchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName; - - _lazyConnection = new Lazy>(CreateConnectionAsync); - } - - public async Task PublishAsync(IIntegrationMessage message) - { - var routingKey = message.IntegrationType.ToRoutingKey(); - var connection = await _lazyConnection.Value; - await using var channel = await connection.CreateChannelAsync(); - - await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Direct, durable: true); - - var body = Encoding.UTF8.GetBytes(message.ToJson()); - - await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: routingKey, body: body); - } - - public async ValueTask DisposeAsync() - { - if (_lazyConnection.IsValueCreated) - { - var connection = await _lazyConnection.Value; - await connection.DisposeAsync(); - } - } - - private async Task CreateConnectionAsync() - { - return await _factory.CreateConnectionAsync(); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs deleted file mode 100644 index a767776c36..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/SlackEventHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Bit.Core.AdminConsole.Models.Data.Integrations; -using Bit.Core.Enums; -using Bit.Core.Repositories; - -#nullable enable - -namespace Bit.Core.Services; - -public class SlackEventHandler( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationIntegrationConfigurationRepository configurationRepository, - ISlackService slackService) - : IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) -{ - protected override IntegrationType GetIntegrationType() => IntegrationType.Slack; - - protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, - string renderedTemplate) - { - var config = mergedConfiguration.Deserialize(); - if (config is null) - { - return; - } - - await slackService.SendSlackMessageByChannelIdAsync( - config.token, - renderedTemplate, - config.channelId - ); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs deleted file mode 100644 index 97453497bc..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/WebhookEventHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using Bit.Core.AdminConsole.Models.Data.Integrations; -using Bit.Core.Enums; -using Bit.Core.Repositories; - -#nullable enable - -namespace Bit.Core.Services; - -public class WebhookEventHandler( - IHttpClientFactory httpClientFactory, - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IOrganizationIntegrationConfigurationRepository configurationRepository) - : IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository) -{ - private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); - - public const string HttpClientName = "WebhookEventHandlerHttpClient"; - - protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook; - - protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, - string renderedTemplate) - { - var config = mergedConfiguration.Deserialize(); - if (config is null || string.IsNullOrEmpty(config.url)) - { - return; - } - - var content = new StringContent(renderedTemplate, Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync(config.url, content); - response.EnsureSuccessStatusCode(); - } -} diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs index d96c4a0ce1..b1ff5b1c4a 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs @@ -116,7 +116,7 @@ public class NoopEventService : IEventService return Task.FromResult(0); } - public Task LogServiceAccountSecretEventAsync(Guid serviceAccountId, Secret secret, EventType type, + public Task LogUserSecretsEventAsync(Guid userId, IEnumerable secrets, EventType type, DateTime? date = null) { return Task.FromResult(0); diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs index 94c1096b58..2bf4a54a87 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities.Provider; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.Billing.Models; using Bit.Core.Entities; diff --git a/src/Core/AdminConsole/Services/OrganizationFactory.cs b/src/Core/AdminConsole/Services/OrganizationFactory.cs new file mode 100644 index 0000000000..dbc8f0fa21 --- /dev/null +++ b/src/Core/AdminConsole/Services/OrganizationFactory.cs @@ -0,0 +1,114 @@ +using System.Security.Claims; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Services; + +public static class OrganizationFactory +{ + public static Organization Create( + User owner, + ClaimsPrincipal claimsPrincipal, + string publicKey, + string privateKey) => new() + { + Name = claimsPrincipal.GetValue(OrganizationLicenseConstants.Name), + BillingEmail = claimsPrincipal.GetValue(OrganizationLicenseConstants.BillingEmail), + BusinessName = claimsPrincipal.GetValue(OrganizationLicenseConstants.BusinessName), + PlanType = claimsPrincipal.GetValue(OrganizationLicenseConstants.PlanType), + Seats = claimsPrincipal.GetValue(OrganizationLicenseConstants.Seats), + MaxCollections = claimsPrincipal.GetValue(OrganizationLicenseConstants.MaxCollections), + MaxStorageGb = 10240, + UsePolicies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePolicies), + UseSso = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseSso), + UseKeyConnector = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseKeyConnector), + UseScim = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseScim), + UseGroups = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseGroups), + UseDirectory = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseDirectory), + UseEvents = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseEvents), + UseTotp = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseTotp), + Use2fa = claimsPrincipal.GetValue(OrganizationLicenseConstants.Use2fa), + UseApi = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseApi), + UseResetPassword = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseResetPassword), + Plan = claimsPrincipal.GetValue(OrganizationLicenseConstants.Plan), + SelfHost = claimsPrincipal.GetValue(OrganizationLicenseConstants.SelfHost), + UsersGetPremium = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsersGetPremium), + UseCustomPermissions = + claimsPrincipal.GetValue(OrganizationLicenseConstants.UseCustomPermissions), + Gateway = null, + GatewayCustomerId = null, + GatewaySubscriptionId = null, + ReferenceData = owner.ReferenceData, + Enabled = claimsPrincipal.GetValue(OrganizationLicenseConstants.Enabled), + ExpirationDate = claimsPrincipal.GetValue(OrganizationLicenseConstants.Expires), + LicenseKey = claimsPrincipal.GetValue(OrganizationLicenseConstants.LicenseKey), + PublicKey = publicKey, + PrivateKey = privateKey, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + Status = OrganizationStatusType.Created, + UsePasswordManager = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePasswordManager), + UseSecretsManager = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseSecretsManager), + SmSeats = claimsPrincipal.GetValue(OrganizationLicenseConstants.SmSeats), + SmServiceAccounts = claimsPrincipal.GetValue(OrganizationLicenseConstants.SmServiceAccounts), + UseRiskInsights = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseRiskInsights), + UseOrganizationDomains = + claimsPrincipal.GetValue(OrganizationLicenseConstants.UseOrganizationDomains), + UseAdminSponsoredFamilies = + claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAdminSponsoredFamilies), + }; + + public static Organization Create( + User owner, + OrganizationLicense license, + string publicKey, + string privateKey) => new() + { + Name = license.Name, + BillingEmail = license.BillingEmail, + BusinessName = license.BusinessName, + PlanType = license.PlanType, + Seats = license.Seats, + MaxCollections = license.MaxCollections, + MaxStorageGb = 10240, + UsePolicies = license.UsePolicies, + UseSso = license.UseSso, + UseKeyConnector = license.UseKeyConnector, + UseScim = license.UseScim, + UseGroups = license.UseGroups, + UseDirectory = license.UseDirectory, + UseEvents = license.UseEvents, + UseTotp = license.UseTotp, + Use2fa = license.Use2fa, + UseApi = license.UseApi, + UseResetPassword = license.UseResetPassword, + Plan = license.Plan, + SelfHost = license.SelfHost, + UsersGetPremium = license.UsersGetPremium, + UseCustomPermissions = license.UseCustomPermissions, + Gateway = null, + GatewayCustomerId = null, + GatewaySubscriptionId = null, + ReferenceData = owner.ReferenceData, + Enabled = license.Enabled, + ExpirationDate = license.Expires, + LicenseKey = license.LicenseKey, + PublicKey = publicKey, + PrivateKey = privateKey, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + Status = OrganizationStatusType.Created, + UsePasswordManager = license.UsePasswordManager, + UseSecretsManager = license.UseSecretsManager, + SmSeats = license.SmSeats, + SmServiceAccounts = license.SmServiceAccounts, + UseRiskInsights = license.UseRiskInsights, + UseOrganizationDomains = license.UseOrganizationDomains, + UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies, + }; +} diff --git a/src/Core/AdminConsole/Utilities/DebuggingInstruments/UserInviteDebuggingLogger.cs b/src/Core/AdminConsole/Utilities/DebuggingInstruments/UserInviteDebuggingLogger.cs new file mode 100644 index 0000000000..6a0e581522 --- /dev/null +++ b/src/Core/AdminConsole/Utilities/DebuggingInstruments/UserInviteDebuggingLogger.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Microsoft.Extensions.Logging; +using Quartz.Util; + +namespace Bit.Core.AdminConsole.Utilities.DebuggingInstruments; + +/// +/// Temporary code: Log warning when OrganizationUser is in an invalid state, +/// so we can identify which flow is causing the issue through Datadog. +/// +public static class UserInviteDebuggingLogger +{ + public static void LogUserInviteStateDiagnostics(this ILogger logger, OrganizationUser orgUser) + { + LogUserInviteStateDiagnostics(logger, [orgUser]); + } + + public static void LogUserInviteStateDiagnostics(this ILogger logger, IEnumerable allOrgUsers) + { + try + { + var invalidInviteState = allOrgUsers.Any(user => user.Status == OrganizationUserStatusType.Invited && user.Email.IsNullOrWhiteSpace()); + + if (invalidInviteState) + { + var logData = MapObjectDataToLog(allOrgUsers); + logger.LogWarning("Warning invalid invited state. {logData}", logData); + } + + var invalidConfirmedOrAcceptedState = allOrgUsers.Any(user => (user.Status == OrganizationUserStatusType.Confirmed || user.Status == OrganizationUserStatusType.Accepted) && !user.Email.IsNullOrWhiteSpace()); + + if (invalidConfirmedOrAcceptedState) + { + var logData = MapObjectDataToLog(allOrgUsers); + logger.LogWarning("Warning invalid confirmed or accepted state. {logData}", logData); + } + } + catch (Exception exception) + { + + // Ensure that this debugging instrument does not interfere with the current flow. + logger.LogWarning(exception, "Unexpected exception from UserInviteDebuggingLogger"); + } + } + + private static string MapObjectDataToLog(IEnumerable allOrgUsers) + { + var log = allOrgUsers.Select(allOrgUser => new + { + allOrgUser.OrganizationId, + allOrgUser.Status, + hasEmail = !allOrgUser.Email.IsNullOrWhiteSpace(), + userId = allOrgUser.UserId, + allOrgUserId = allOrgUser.Id + }); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + return JsonSerializer.Serialize(log, options); + } +} diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs index aab4e448e5..dceeea85f4 100644 --- a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -1,5 +1,6 @@ #nullable enable +using System.Text.Json; using System.Text.RegularExpressions; namespace Bit.Core.AdminConsole.Utilities; @@ -19,8 +20,15 @@ public static partial class IntegrationTemplateProcessor return TokenRegex().Replace(template, match => { var propertyName = match.Groups[1].Value; - var property = type.GetProperty(propertyName); - return property?.GetValue(values)?.ToString() ?? match.Value; + if (propertyName == "EventMessage") + { + return JsonSerializer.Serialize(values); + } + else + { + var property = type.GetProperty(propertyName); + return property?.GetValue(values)?.ToString() ?? match.Value; + } }); } diff --git a/src/Core/Auth/Entities/AuthRequest.cs b/src/Core/Auth/Entities/AuthRequest.cs index 088c24b88a..2117c575c0 100644 --- a/src/Core/Auth/Entities/AuthRequest.cs +++ b/src/Core/Auth/Entities/AuthRequest.cs @@ -1,4 +1,8 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; +using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Utilities; @@ -40,11 +44,31 @@ public class AuthRequest : ITableObject public bool IsSpent() { - return ResponseDate.HasValue || AuthenticationDate.HasValue || GetExpirationDate() < DateTime.UtcNow; + return ResponseDate.HasValue || AuthenticationDate.HasValue || IsExpired(); + } + + public bool IsExpired() + { + // TODO: PM-24252 - consider using TimeProvider for better mocking in tests + return GetExpirationDate() < DateTime.UtcNow; + } + + // TODO: PM-24252 - this probably belongs in a service. + public bool IsValidForAuthentication(Guid userId, + string password) + { + return ResponseDate.HasValue // it’s been responded to + && Approved == true // it was approved + && !IsExpired() // it's not expired + && Type == AuthRequestType.AuthenticateAndUnlock // it’s an authN request + && !AuthenticationDate.HasValue // it was not already used for authN + && UserId == userId // it belongs to the user + && CoreHelpers.FixedTimeEquals(AccessCode, password); // the access code matches the password } public DateTime GetExpirationDate() { + // TODO: PM-24252 - this should reference PasswordlessAuthSettings.UserRequestExpiration return CreationDate.AddMinutes(15); } } diff --git a/src/Core/Auth/Entities/EmergencyAccess.cs b/src/Core/Auth/Entities/EmergencyAccess.cs index f295f25604..d855126468 100644 --- a/src/Core/Auth/Entities/EmergencyAccess.cs +++ b/src/Core/Auth/Entities/EmergencyAccess.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Utilities; diff --git a/src/Core/Auth/Entities/SsoConfig.cs b/src/Core/Auth/Entities/SsoConfig.cs index c872928031..bbe5e87962 100644 --- a/src/Core/Auth/Entities/SsoConfig.cs +++ b/src/Core/Auth/Entities/SsoConfig.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; namespace Bit.Core.Auth.Entities; diff --git a/src/Core/Auth/Entities/SsoUser.cs b/src/Core/Auth/Entities/SsoUser.cs index 3199f00221..eb3250f310 100644 --- a/src/Core/Auth/Entities/SsoUser.cs +++ b/src/Core/Auth/Entities/SsoUser.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; namespace Bit.Core.Auth.Entities; @@ -8,7 +11,7 @@ public class SsoUser : ITableObject public long Id { get; set; } public Guid UserId { get; set; } public Guid? OrganizationId { get; set; } - [MaxLength(50)] + [MaxLength(300)] public string ExternalId { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; diff --git a/src/Core/Auth/Entities/WebAuthnCredential.cs b/src/Core/Auth/Entities/WebAuthnCredential.cs index 486fd41e3f..ecc763088d 100644 --- a/src/Core/Auth/Entities/WebAuthnCredential.cs +++ b/src/Core/Auth/Entities/WebAuthnCredential.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Utilities; diff --git a/src/Core/Auth/Enums/EmergencyAccessType.cs b/src/Core/Auth/Enums/EmergencyAccessType.cs index a3497cc287..6e4e6e7f56 100644 --- a/src/Core/Auth/Enums/EmergencyAccessType.cs +++ b/src/Core/Auth/Enums/EmergencyAccessType.cs @@ -2,6 +2,12 @@ public enum EmergencyAccessType : byte { + /// + /// Allows emergency contact to view the Grantor's data. + /// View = 0, + /// + /// Allows emergency contact to take over the Grantor's account by overwriting the Grantor's password. + /// Takeover = 1, } diff --git a/src/Core/Auth/Enums/TwoFactorEmailPurpose.cs b/src/Core/Auth/Enums/TwoFactorEmailPurpose.cs new file mode 100644 index 0000000000..47ac2e0e3c --- /dev/null +++ b/src/Core/Auth/Enums/TwoFactorEmailPurpose.cs @@ -0,0 +1,8 @@ +namespace Core.Auth.Enums; + +public enum TwoFactorEmailPurpose +{ + Login, + Setup, + NewDeviceVerification, +} diff --git a/src/Core/Auth/Identity/LowerInvariantLookupNormalizer.cs b/src/Core/Auth/Identity/LowerInvariantLookupNormalizer.cs index 1318d94760..7f058ed5d4 100644 --- a/src/Core/Auth/Identity/LowerInvariantLookupNormalizer.cs +++ b/src/Core/Auth/Identity/LowerInvariantLookupNormalizer.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Identity; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.Identity; diff --git a/src/Core/Auth/Identity/RoleStore.cs b/src/Core/Auth/Identity/RoleStore.cs index 3ea530dd04..388f904e71 100644 --- a/src/Core/Auth/Identity/RoleStore.cs +++ b/src/Core/Auth/Identity/RoleStore.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.Identity; diff --git a/src/Core/Auth/Identity/TokenProviders/AuthenticatorTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/AuthenticatorTokenProvider.cs index 5a3d9522f3..6348d6f27b 100644 --- a/src/Core/Auth/Identity/TokenProviders/AuthenticatorTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/AuthenticatorTokenProvider.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; diff --git a/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenProvider.cs index 3f2a44915c..6ed715b14b 100644 --- a/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenProvider.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Entities; diff --git a/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenService.cs b/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenService.cs index 8dd07e7ee6..a59a76de0a 100644 --- a/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenService.cs +++ b/src/Core/Auth/Identity/TokenProviders/DuoUniversalTokenService.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; using Bit.Core.Entities; diff --git a/src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs index be94124c03..70aba8ef75 100644 --- a/src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs @@ -1,5 +1,6 @@ using System.Text; using Bit.Core.Entities; +using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; @@ -7,6 +8,9 @@ using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Auth.Identity.TokenProviders; +/// +/// Generates and validates tokens for email OTPs. +/// public class EmailTokenProvider : IUserTwoFactorTokenProvider { private const string CacheKeyFormat = "EmailToken_{0}_{1}_{2}"; @@ -16,16 +20,25 @@ public class EmailTokenProvider : IUserTwoFactorTokenProvider public EmailTokenProvider( [FromKeyedServices("persistent")] - IDistributedCache distributedCache) + IDistributedCache distributedCache, + IFeatureService featureService) { _distributedCache = distributedCache; _distributedCacheEntryOptions = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }; + if (featureService.IsEnabled(FeatureFlagKeys.Otp6Digits)) + { + TokenLength = 6; + } + else + { + TokenLength = 8; + } } - public int TokenLength { get; protected set; } = 8; + public int TokenLength { get; protected set; } public bool TokenAlpha { get; protected set; } = false; public bool TokenNumeric { get; protected set; } = true; diff --git a/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs index 718e44ae5f..2d72781569 100644 --- a/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/EmailTwoFactorTokenProvider.cs @@ -1,19 +1,30 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Entities; +using Bit.Core.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Auth.Identity.TokenProviders; +/// +/// Generates tokens for email two-factor authentication. +/// It inherits from the EmailTokenProvider class, which manages the persistence and validation of tokens, +/// and adds additional validation to ensure that 2FA is enabled for the user. +/// public class EmailTwoFactorTokenProvider : EmailTokenProvider { public EmailTwoFactorTokenProvider( [FromKeyedServices("persistent")] - IDistributedCache distributedCache) : - base(distributedCache) + IDistributedCache distributedCache, + IFeatureService featureService) : + base(distributedCache, featureService) { + // This can be removed when the pm-18612-otp-6-digits feature flag is removed because the base implementation will match. TokenAlpha = false; TokenNumeric = true; TokenLength = 6; @@ -43,7 +54,7 @@ public class EmailTwoFactorTokenProvider : EmailTokenProvider private static bool HasProperMetaData(TwoFactorProvider provider) { - return provider?.MetaData != null && provider.MetaData.ContainsKey("Email") && - !string.IsNullOrWhiteSpace((string)provider.MetaData["Email"]); + return provider?.MetaData != null && provider.MetaData.TryGetValue("Email", out var emailValue) && + !string.IsNullOrWhiteSpace((string)emailValue); } } diff --git a/src/Core/Auth/Identity/TokenProviders/OrganizationDuoUniversalTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/OrganizationDuoUniversalTokenProvider.cs index c8007dd6ec..07768c32c9 100644 --- a/src/Core/Auth/Identity/TokenProviders/OrganizationDuoUniversalTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/OrganizationDuoUniversalTokenProvider.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; diff --git a/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/IOtpTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/IOtpTokenProvider.cs new file mode 100644 index 0000000000..bf153e80eb --- /dev/null +++ b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/IOtpTokenProvider.cs @@ -0,0 +1,29 @@ +namespace Bit.Core.Auth.Identity.TokenProviders; + +/// +/// A generic interface for a one-time password (OTP) token provider. +/// +public interface IOtpTokenProvider + where TOptions : DefaultOtpTokenProviderOptions +{ + /// + /// Generates a new one-time password (OTP) based on the configured parameters. + /// The generated OTP is stored in the distributed cache with a key based on the unique identifier and purpose. If the + /// key is already in use, it will overwrite and generate a new OTP with a refreshed TTL. + /// + /// Name of the token provider, used to distinguish different token providers that may inject this class + /// Purpose of the OTP token, used to distinguish different types of tokens. + /// Unique identifier to distinguish one request from another + /// generated token | null + Task GenerateTokenAsync(string tokenProviderName, string purpose, string uniqueIdentifier); + + /// + /// Validates the provided token against the stored value in the distributed cache. + /// + /// string value matched against the unique identifier in the cache if found + /// Name of the token provider, used to distinguish different token providers that may inject this class + /// Purpose of the OTP token, used to distinguish different types of tokens. + /// Unique identifier to distinguish one request from another + /// true if the token matches what is fetched from the cache, false if not. + Task ValidateTokenAsync(string token, string tokenProviderName, string purpose, string uniqueIdentifier); +} diff --git a/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProvider.cs new file mode 100644 index 0000000000..b6280e13fe --- /dev/null +++ b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProvider.cs @@ -0,0 +1,75 @@ +using System.Text; +using Bit.Core.Utilities; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bit.Core.Auth.Identity.TokenProviders; + +public class OtpTokenProvider( + [FromKeyedServices("persistent")] + IDistributedCache distributedCache, + IOptions options) : IOtpTokenProvider + where TOptions : DefaultOtpTokenProviderOptions +{ + private readonly TOptions _otpTokenProviderOptions = options.Value; + + /// + /// This is where the OTP tokens are stored. + /// + private readonly IDistributedCache _distributedCache = distributedCache; + + /// + /// Used to store and fetch the OTP tokens from the distributed cache. + /// The format is "{tokenProviderName}_{purpose}_{uniqueIdentifier}". + /// + private readonly string _cacheKeyFormat = "{0}_{1}_{2}"; + + public async Task GenerateTokenAsync(string tokenProviderName, string purpose, string uniqueIdentifier) + { + if (string.IsNullOrEmpty(tokenProviderName) + || string.IsNullOrEmpty(purpose) + || string.IsNullOrEmpty(uniqueIdentifier)) + { + return null; + } + + var cacheKey = string.Format(_cacheKeyFormat, tokenProviderName, purpose, uniqueIdentifier); + var token = CoreHelpers.SecureRandomString( + _otpTokenProviderOptions.TokenLength, + _otpTokenProviderOptions.TokenAlpha, + true, + false, + _otpTokenProviderOptions.TokenNumeric, + false); + await _distributedCache.SetAsync(cacheKey, Encoding.UTF8.GetBytes(token), _otpTokenProviderOptions.DistributedCacheEntryOptions); + return token; + } + + public async Task ValidateTokenAsync(string token, string tokenProviderName, string purpose, string uniqueIdentifier) + { + if (string.IsNullOrEmpty(token) + || string.IsNullOrEmpty(tokenProviderName) + || string.IsNullOrEmpty(purpose) + || string.IsNullOrEmpty(uniqueIdentifier)) + { + return false; + } + + var cacheKey = string.Format(_cacheKeyFormat, tokenProviderName, purpose, uniqueIdentifier); + var cachedValue = await _distributedCache.GetAsync(cacheKey); + if (cachedValue == null) + { + return false; + } + + var code = Encoding.UTF8.GetString(cachedValue); + var valid = string.Equals(token, code); + if (valid) + { + await _distributedCache.RemoveAsync(cacheKey); + } + + return valid; + } +} diff --git a/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProviderOptions.cs b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProviderOptions.cs new file mode 100644 index 0000000000..95996d69a6 --- /dev/null +++ b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProviderOptions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Caching.Distributed; + +namespace Bit.Core.Auth.Identity.TokenProviders; + +/// +/// Options for configuring the OTP token provider. +/// +public class DefaultOtpTokenProviderOptions +{ + /// + /// Gets or sets the length of the generated token. + /// Default is 6 characters. + /// + public int TokenLength { get; set; } = 6; + + /// + /// Gets or sets whether the token should contain alphabetic characters. + /// Default is false. + /// + public bool TokenAlpha { get; set; } = false; + + /// + /// Gets or sets whether the token should contain numeric characters. + /// Default is true. + /// + public bool TokenNumeric { get; set; } = true; + + /// + /// Cache entry options for Otp Token provider. + /// Default is 5 minutes expiration. + /// + public DistributedCacheEntryOptions DistributedCacheEntryOptions { get; set; } = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) + }; +} diff --git a/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/readme.md b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/readme.md new file mode 100644 index 0000000000..8cf12a98bf --- /dev/null +++ b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/readme.md @@ -0,0 +1,206 @@ +# OtpTokenProvider + +The `OtpTokenProvider` is a token provider service for generating and validating Time-Based one-time passwords (TOTP). It provides a secure way to create temporary tokens for various authentication and verification scenarios. The provider can be configured to generate tokens specific to your use case by using the options pattern in the DI pipeline. + +## Overview + +The OTP Token Provider generates secure, time-limited tokens that can be used for: + +- Two-factor authentication +- Temporary access tokens for Sends +- Any scenario requiring short-lived verification codes + +## Features + +- **Configurable Token Length**: Default 6 characters, customizable +- **Character Set Options**: Numeric (default), alphabetic, or mixed +- **Distributed Caching**: Uses CosmosDb for cloud, or the configured database otherwise. +- **TTL Management**: Configurable expiration (default 5 minutes) +- **Secure Generation**: Uses cryptographically secure random generation +- **One-Time Use**: Tokens are automatically deleted from the cache after successful validation + +## Architecture + +### Interface: `IOtpTokenProvider` + +```csharp +public interface IOtpTokenProvider + where TOptions : DefaultOtpTokenProviderOptions +{ + Task GenerateTokenAsync(string tokenProviderName, string purpose, string uniqueIdentifier); + Task ValidateTokenAsync(string token, string tokenProviderName, string purpose, string uniqueIdentifier); +} +``` + +### Implementation: `OtpTokenProvider` + +The provider is initialized with: + +- **Distributed Cache**: Storage backend for tokens (using "persistent" keyed service) +- **IOptions**: Configuration options for token generation and caching + +## Usage + +### Basic Setup + +If your class needs the use the `IOtpTokenProvider` you can inject it like any other injectable class from the DI. + +### Generating a Token + +```csharp +// Generate a new OTP with token provider name, purpose and unique identifier +string token = await otpProvider.GenerateTokenAsync("EmailToken", "email_verification", $"{userId}_{securityStamp}"); +// Returns: "123456" (6-digit numeric by default) +``` + +### Validating a Token + +```csharp +// Validate user-provided token with same parameters used for generation +bool isValid = await otpProvider.ValidateTokenAsync("123456", "EmailToken", "email_verification", $"{userId}_{securityStamp}"); +// Returns: true if valid, false otherwise +// Note: Valid tokens are automatically removed from cache +``` + +### Custom Configurations + +If you need to modify the default options you can do so by creating an extension of the `DefaultOtpTokenProviderOptions` and using that class as the TOptions when injecting another IOtpTokenProvider service. + +#### OtpTokenProviderOptions + +```csharp +public class DefaultOtpTokenProviderOptions +{ ... } + +public class UserEmailOtpTokenOptions : DefaultOtpTokenProviderOptions { } +``` + +#### Service Collection + +```csharp +public static IdentityBuilder AddCustomIdentityServices( + this IServiceCollection services, GlobalSettings globalSettings) +{ + // possible customization + services.Configure(options => + { + options.TokenLength = 8; + // The other options are left default + }); + + // TryAddTransient open generics -> this allows us to inject IOtpTokenProvider without having to specify the specific type here. + services.TryAddTransient(typeof(IOtpTokenProvider<>), typeof(OtpTokenProvider<>); +} +``` + +#### Usage + +```csharp +public class UserEmailTokenProvider( + IOtpTokenProvider otpTokenProvider +) +{ + private readonly IOtpTokenProvider _otpTokenProvider = otpTokenProvider; + ... +} +``` + +## Configuration Options + +### Token Properties + +| Property | Default | Description | +| -------------- | ------- | ---------------------------------------- | +| `TokenLength` | 6 | Number of characters in generated token | +| `TokenAlpha` | false | Include alphabetic characters (a-z, A-Z) | +| `TokenNumeric` | true | Include numeric characters (0-9) | + +### Cache Options + +See `DistributedCacheEntryOptions` documentation for a complete list of configuration options. + +| Property | Default | Description | +| --------------------------------- | --------- | ---------------------------- | +| `AbsoluteExpirationRelativeToNow` | 5 minutes | How long tokens remain valid | + +## Cache Key Format + +The cache key format uses three components: `{tokenProviderName}_{purpose}_{uniqueIdentifier}` + +### Examples: + +#### Possible Email Token Provider Example + +Email token provider uses: + +- **Token Provider Name**: `"EmailToken"` (identifies the specific use case) +- **Purpose**: `"EmailTwoFactorAuthentication"` (specific action being verified) +- **Unique Identifier**: `"{user.Id}_{securityStamp}"` (user-specific data) + +These are passed into the OTP Token Provider which creates a cache record: + +- Cache Key: `EmailToken_EmailTwoFactorAuthentication_guid_guid` + +## Security Considerations + +### Token Generation + +- Uses `CoreHelpers.SecureRandomString()` for cryptographically secure randomness +- No predictable patterns in generated tokens +- Configurable character sets for different security requirements + +### Storage + +- Tokens are stored in distributed cache. The cache depends on the specific deployment, for cloud it is CosmosDb. +- Automatic expiration prevents indefinite token validity +- One-time use prevents replay attacks + +### Validation + +- Exact string matching for validation +- Automatic removal after successful validation +- Returns `false` for expired or non-existent tokens + +## Dependency Injection + +The provider is registered in `ServiceCollectionExtensions.cs`: + +```csharp +services.TryAddScoped, OtpTokenProvider>(); +``` + +## Error Handling + +### Common Scenarios + +- **Token Not Found**: `ValidateTokenAsync()` returns `false` +- **Token Expired**: Automatically cleaned up by cache, validation returns `false` +- **Invalid Input**: + - `GenerateTokenAsync` returns `null` for empty/null tokenProviderName, purpose, or uniqueIdentifier + - `ValidateTokenAsync` returns `false` for empty/null token, tokenProviderName, purpose, or uniqueIdentifier + - No cache operations are performed for invalid inputs + +### Best Practices + +- Always check validation results +- Handle token expiration gracefully +- Provide clear user feedback for invalid tokens +- Implement rate limiting for token generation + +## Related Components + +- **`CoreHelpers.SecureRandomString()`**: Secure token generation +- **`IDistributedCache`**: Token storage backend +- **Two-Factor Authentication Providers**: Integration with 2FA flows +- **Email Services**: A Token delivery mechanism + +## Testing + +When testing components that use `OtpTokenProvider`: + +```csharp +// Mock the interface for unit tests +var mockOtpProvider = Substitute.For>(); +mockOtpProvider.GenerateTokenAsync("EmailToken", "email_verification", "user_123").Returns("123456"); +mockOtpProvider.ValidateTokenAsync("123456", "EmailToken", "email_verification", "user_123").Returns(true); +``` diff --git a/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs index 0bf75d0fc3..60fb2c5635 100644 --- a/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/WebAuthnTokenProvider.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Entities; @@ -80,7 +83,7 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn); var keys = LoadKeys(provider); - if (!provider.MetaData.TryGetValue("login", out var value)) + if (!provider.MetaData.TryGetValue("login", out var login)) { return false; } @@ -88,7 +91,7 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider var clientResponse = JsonSerializer.Deserialize(token, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - var jsonOptions = value.ToString(); + var jsonOptions = login.ToString(); var options = AssertionOptions.FromJson(jsonOptions); var webAuthCred = keys.Find(k => k.Item2.Descriptor.Id.SequenceEqual(clientResponse.Id)); @@ -148,9 +151,9 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider for (var i = 1; i <= 5; i++) { var keyName = $"Key{i}"; - if (provider.MetaData.ContainsKey(keyName)) + if (provider.MetaData.TryGetValue(keyName, out var value)) { - var key = new TwoFactorProvider.WebAuthnData((dynamic)provider.MetaData[keyName]); + var key = new TwoFactorProvider.WebAuthnData((dynamic)value); keys.Add(new Tuple(keyName, key)); } diff --git a/src/Core/Auth/Identity/TokenProviders/YubicoOtpTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/YubicoOtpTokenProvider.cs index b33d2fc0c9..ddac1843ec 100644 --- a/src/Core/Auth/Identity/TokenProviders/YubicoOtpTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/YubicoOtpTokenProvider.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Services; using Bit.Core.Settings; diff --git a/src/Core/Auth/Identity/UserStore.cs b/src/Core/Auth/Identity/UserStore.cs index 41323f05b7..e8ae95a0bd 100644 --- a/src/Core/Auth/Identity/UserStore.cs +++ b/src/Core/Auth/Identity/UserStore.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Repositories; diff --git a/src/Core/Auth/IdentityServer/TokenRetrieval.cs b/src/Core/Auth/IdentityServer/TokenRetrieval.cs index 36c23506cb..bf0230bafb 100644 --- a/src/Core/Auth/IdentityServer/TokenRetrieval.cs +++ b/src/Core/Auth/IdentityServer/TokenRetrieval.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Http; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Http; namespace Bit.Core.Auth.IdentityServer; diff --git a/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs index 0964fe1a1d..f89b67f3c5 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/KeysRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Utilities; diff --git a/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestCreateRequestModel.cs b/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestCreateRequestModel.cs index e7cd05be20..7fbc5f19b1 100644 --- a/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestCreateRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestCreateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Enums; namespace Bit.Core.Auth.Models.Api.Request.AuthRequest; diff --git a/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestUpdateRequestModel.cs b/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestUpdateRequestModel.cs index 1577f3a1c8..c834ec8e55 100644 --- a/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestUpdateRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/AuthRequest/AuthRequestUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Auth.Models.Api.Request.AuthRequest; diff --git a/src/Core/Auth/Models/Api/Request/DeviceKeysUpdateRequestModel.cs b/src/Core/Auth/Models/Api/Request/DeviceKeysUpdateRequestModel.cs index 111b03a3a3..bcd648d1fb 100644 --- a/src/Core/Auth/Models/Api/Request/DeviceKeysUpdateRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/DeviceKeysUpdateRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Entities; using Bit.Core.Utilities; diff --git a/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs b/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs index 6a0641246b..6c323d6207 100644 --- a/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs +++ b/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs @@ -1,4 +1,7 @@ - +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + + using Bit.Core.Models.Api; using Fido2NetLib; diff --git a/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs b/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs index 3e3076e84e..47a308b28d 100644 --- a/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs +++ b/src/Core/Auth/Models/Api/Response/DeviceAuthRequestResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models.Data; using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Utilities; diff --git a/src/Core/Auth/Models/Api/Response/ProtectedDeviceResponseModel.cs b/src/Core/Auth/Models/Api/Response/ProtectedDeviceResponseModel.cs index c64c552977..c2fff4afee 100644 --- a/src/Core/Auth/Models/Api/Response/ProtectedDeviceResponseModel.cs +++ b/src/Core/Auth/Models/Api/Response/ProtectedDeviceResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Api; diff --git a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs index b5f2b77cfb..bd8542e8bf 100644 --- a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs +++ b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs @@ -1,8 +1,7 @@ using System.Text.Json.Serialization; +using Bit.Core.KeyManagement.Models.Response; using Bit.Core.Models.Api; -#nullable enable - namespace Bit.Core.Auth.Models.Api.Response; public class UserDecryptionOptions : ResponseModel @@ -14,8 +13,15 @@ public class UserDecryptionOptions : ResponseModel /// /// Gets or sets whether the current user has a master password that can be used to decrypt their vault. /// + [Obsolete("Use MasterPasswordUnlock instead. This will be removed in a future version.")] public bool HasMasterPassword { get; set; } + /// + /// Gets or sets whether the current user has master password unlock data available. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MasterPasswordUnlockResponseModel? MasterPasswordUnlock { get; set; } + /// /// Gets or sets the WebAuthn PRF decryption keys. /// diff --git a/src/Core/Auth/Models/Business/Tokenables/EmergencyAccessInviteTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/EmergencyAccessInviteTokenable.cs index a29afdf1fb..8e8cc41653 100644 --- a/src/Core/Auth/Models/Business/Tokenables/EmergencyAccessInviteTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/EmergencyAccessInviteTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Auth.Entities; namespace Bit.Core.Auth.Models.Business.Tokenables; diff --git a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs index 95c84ad3b5..f04a1181c4 100644 --- a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Entities; using Bit.Core.Tokens; diff --git a/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs index 006da70080..03fcb9b5c0 100644 --- a/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/RegistrationEmailVerificationTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Tokens; namespace Bit.Core.Auth.Models.Business.Tokenables; diff --git a/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs index 30687a6a4a..eeffe0bedc 100644 --- a/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/SsoEmail2faSessionTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Entities; using Bit.Core.Tokens; diff --git a/src/Core/Auth/Models/Business/Tokenables/SsoTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/SsoTokenable.cs index 48386c5439..06e2dda3d9 100644 --- a/src/Core/Auth/Models/Business/Tokenables/SsoTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/SsoTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities; using Bit.Core.Tokens; diff --git a/src/Core/Auth/Models/Business/Tokenables/TwoFactorAuthenticatorUserVerificationTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/TwoFactorAuthenticatorUserVerificationTokenable.cs index 70a94f5928..76e54374e0 100644 --- a/src/Core/Auth/Models/Business/Tokenables/TwoFactorAuthenticatorUserVerificationTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/TwoFactorAuthenticatorUserVerificationTokenable.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Tokens; using Newtonsoft.Json; diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs index e64edace45..049681a028 100644 --- a/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/WebAuthnCredentialCreateOptionsTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Entities; using Bit.Core.Tokens; using Fido2NetLib; diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs index 017033b00a..bbea66a6b1 100644 --- a/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Auth.Enums; using Bit.Core.Tokens; using Fido2NetLib; diff --git a/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs b/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs index 15ccad9cb1..03661c7276 100644 --- a/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs +++ b/src/Core/Auth/Models/Data/EmergencyAccessDetails.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Entities; namespace Bit.Core.Auth.Models.Data; diff --git a/src/Core/Auth/Models/Data/EmergencyAccessNotify.cs b/src/Core/Auth/Models/Data/EmergencyAccessNotify.cs index f3f1347338..1c0d4bfe8b 100644 --- a/src/Core/Auth/Models/Data/EmergencyAccessNotify.cs +++ b/src/Core/Auth/Models/Data/EmergencyAccessNotify.cs @@ -1,4 +1,7 @@ - +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + + using Bit.Core.Auth.Entities; namespace Bit.Core.Auth.Models.Data; diff --git a/src/Core/Auth/Models/Data/EmergencyAccessViewData.cs b/src/Core/Auth/Models/Data/EmergencyAccessViewData.cs index 70130e0fcf..1e5916d6af 100644 --- a/src/Core/Auth/Models/Data/EmergencyAccessViewData.cs +++ b/src/Core/Auth/Models/Data/EmergencyAccessViewData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Entities; using Bit.Core.Vault.Models.Data; namespace Bit.Core.Auth.Models.Data; diff --git a/src/Core/Auth/Models/Data/GrantItem.cs b/src/Core/Auth/Models/Data/GrantItem.cs index de856904db..6bf99c019b 100644 --- a/src/Core/Auth/Models/Data/GrantItem.cs +++ b/src/Core/Auth/Models/Data/GrantItem.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json.Serialization; using Bit.Core.Auth.Repositories.Cosmos; using Duende.IdentityServer.Models; diff --git a/src/Core/Auth/Models/Data/IGrant.cs b/src/Core/Auth/Models/Data/IGrant.cs index 5f14631533..1465194a66 100644 --- a/src/Core/Auth/Models/Data/IGrant.cs +++ b/src/Core/Auth/Models/Data/IGrant.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Auth.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Auth.Models.Data; public interface IGrant { diff --git a/src/Core/Auth/Models/Data/OrganizationAdminAuthRequest.cs b/src/Core/Auth/Models/Data/OrganizationAdminAuthRequest.cs index cd3a98efcc..297f2d0120 100644 --- a/src/Core/Auth/Models/Data/OrganizationAdminAuthRequest.cs +++ b/src/Core/Auth/Models/Data/OrganizationAdminAuthRequest.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; namespace Bit.Core.Auth.Models.Data; diff --git a/src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs b/src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs new file mode 100644 index 0000000000..0755e941b7 --- /dev/null +++ b/src/Core/Auth/Models/Data/PendingAuthRequestDetails.cs @@ -0,0 +1,83 @@ + +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Enums; + +namespace Bit.Core.Auth.Models.Data; + +public class PendingAuthRequestDetails : AuthRequest +{ + public Guid? RequestDeviceId { get; set; } + + /** + * Constructor for EF response. + */ + public PendingAuthRequestDetails( + AuthRequest authRequest, + Guid? deviceId) + { + ArgumentNullException.ThrowIfNull(authRequest); + + Id = authRequest.Id; + UserId = authRequest.UserId; + OrganizationId = authRequest.OrganizationId; + Type = authRequest.Type; + RequestDeviceIdentifier = authRequest.RequestDeviceIdentifier; + RequestDeviceType = authRequest.RequestDeviceType; + RequestIpAddress = authRequest.RequestIpAddress; + RequestCountryName = authRequest.RequestCountryName; + ResponseDeviceId = authRequest.ResponseDeviceId; + AccessCode = authRequest.AccessCode; + PublicKey = authRequest.PublicKey; + Key = authRequest.Key; + MasterPasswordHash = authRequest.MasterPasswordHash; + Approved = authRequest.Approved; + CreationDate = authRequest.CreationDate; + ResponseDate = authRequest.ResponseDate; + AuthenticationDate = authRequest.AuthenticationDate; + RequestDeviceId = deviceId; + } + + /** + * Constructor for dapper response. + */ + public PendingAuthRequestDetails( + Guid id, + Guid userId, + Guid organizationId, + short type, + string requestDeviceIdentifier, + short requestDeviceType, + string requestIpAddress, + string requestCountryName, + Guid? responseDeviceId, + string accessCode, + string publicKey, + string key, + string masterPasswordHash, + bool? approved, + DateTime creationDate, + DateTime? responseDate, + DateTime? authenticationDate, + Guid deviceId) + { + Id = id; + UserId = userId; + OrganizationId = organizationId; + Type = (AuthRequestType)type; + RequestDeviceIdentifier = requestDeviceIdentifier; + RequestDeviceType = (DeviceType)requestDeviceType; + RequestIpAddress = requestIpAddress; + RequestCountryName = requestCountryName; + ResponseDeviceId = responseDeviceId; + AccessCode = accessCode; + PublicKey = publicKey; + Key = key; + MasterPasswordHash = masterPasswordHash; + Approved = approved; + CreationDate = creationDate; + ResponseDate = responseDate; + AuthenticationDate = authenticationDate; + RequestDeviceId = deviceId; + } +} diff --git a/src/Core/Auth/Models/Data/SsoConfigurationData.cs b/src/Core/Auth/Models/Data/SsoConfigurationData.cs index fe39a5a054..e4ff7af729 100644 --- a/src/Core/Auth/Models/Data/SsoConfigurationData.cs +++ b/src/Core/Auth/Models/Data/SsoConfigurationData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authentication.OpenIdConnect; diff --git a/src/Core/Auth/Models/Data/WebAuthnLoginRotateKeyData.cs b/src/Core/Auth/Models/Data/WebAuthnLoginRotateKeyData.cs index 40a096c474..5004d35e03 100644 --- a/src/Core/Auth/Models/Data/WebAuthnLoginRotateKeyData.cs +++ b/src/Core/Auth/Models/Data/WebAuthnLoginRotateKeyData.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; namespace Bit.Core.Auth.Models.Data; diff --git a/src/Core/Auth/Models/ITwoFactorProvidersUser.cs b/src/Core/Auth/Models/ITwoFactorProvidersUser.cs index f953e4570e..5cf137b76f 100644 --- a/src/Core/Auth/Models/ITwoFactorProvidersUser.cs +++ b/src/Core/Auth/Models/ITwoFactorProvidersUser.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Services; namespace Bit.Core.Auth.Models; diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessAcceptedViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessAcceptedViewModel.cs index afe29b9843..cbe6dbec1c 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessAcceptedViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessAcceptedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessAcceptedViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessApprovedViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessApprovedViewModel.cs index 9ad446aab6..65d80c06cb 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessApprovedViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessApprovedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessApprovedViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessConfirmedViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessConfirmedViewModel.cs index 2ab55a05eb..4527dfddb0 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessConfirmedViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessConfirmedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessConfirmedViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessInvitedViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessInvitedViewModel.cs index fa432c5b70..5f9e450a0c 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessInvitedViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessInvitedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessInvitedViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryTimedOutViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryTimedOutViewModel.cs index dd3ae3dd82..6d166b3ebb 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryTimedOutViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryTimedOutViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessRecoveryTimedOutViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryViewModel.cs index 3811b49ff0..743a0707fc 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessRecoveryViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessRecoveryViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/EmergencyAccessRejectedViewModel.cs b/src/Core/Auth/Models/Mail/EmergencyAccessRejectedViewModel.cs index 101cb9c167..c704e121a3 100644 --- a/src/Core/Auth/Models/Mail/EmergencyAccessRejectedViewModel.cs +++ b/src/Core/Auth/Models/Mail/EmergencyAccessRejectedViewModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Models.Mail; public class EmergencyAccessRejectedViewModel : BaseMailModel { diff --git a/src/Core/Auth/Models/Mail/FailedAuthAttemptModel.cs b/src/Core/Auth/Models/Mail/FailedAuthAttemptModel.cs new file mode 100644 index 0000000000..c67ac4a3d3 --- /dev/null +++ b/src/Core/Auth/Models/Mail/FailedAuthAttemptModel.cs @@ -0,0 +1,13 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; +using Bit.Core.Models.Mail; + +namespace Bit.Core.Auth.Models.Mail; + +public class FailedAuthAttemptModel : NewDeviceLoggedInModel +{ + public string AffectedEmail { get; set; } + public TwoFactorProviderType TwoFactorType { get; set; } +} diff --git a/src/Core/Auth/Models/Mail/FailedAuthAttemptsModel.cs b/src/Core/Auth/Models/Mail/FailedAuthAttemptsModel.cs deleted file mode 100644 index 2d5bc7eb15..0000000000 --- a/src/Core/Auth/Models/Mail/FailedAuthAttemptsModel.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.Models.Mail; - -namespace Bit.Core.Auth.Models.Mail; - -public class FailedAuthAttemptsModel : NewDeviceLoggedInModel -{ - public string AffectedEmail { get; set; } -} diff --git a/src/Core/Auth/Models/Mail/MasterPasswordHintViewModel.cs b/src/Core/Auth/Models/Mail/MasterPasswordHintViewModel.cs index e8ee28fc11..2c2f8343ea 100644 --- a/src/Core/Auth/Models/Mail/MasterPasswordHintViewModel.cs +++ b/src/Core/Auth/Models/Mail/MasterPasswordHintViewModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.Auth.Models.Mail; diff --git a/src/Core/Auth/Models/Mail/PasswordlessSignInModel.cs b/src/Core/Auth/Models/Mail/PasswordlessSignInModel.cs index 4b195b54a8..e8a07a7ec5 100644 --- a/src/Core/Auth/Models/Mail/PasswordlessSignInModel.cs +++ b/src/Core/Auth/Models/Mail/PasswordlessSignInModel.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Auth.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Auth.Models.Mail; public class PasswordlessSignInModel { diff --git a/src/Core/Auth/Models/Mail/RecoverTwoFactorModel.cs b/src/Core/Auth/Models/Mail/RecoverTwoFactorModel.cs index 82c2bc5303..ba11f3a442 100644 --- a/src/Core/Auth/Models/Mail/RecoverTwoFactorModel.cs +++ b/src/Core/Auth/Models/Mail/RecoverTwoFactorModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.Auth.Models.Mail; diff --git a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs index f1863da691..fe42093111 100644 --- a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs +++ b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.Auth.Models.Mail; diff --git a/src/Core/Auth/Models/Mail/VerifyDeleteModel.cs b/src/Core/Auth/Models/Mail/VerifyDeleteModel.cs index d6b7b3a445..44b88a69a8 100644 --- a/src/Core/Auth/Models/Mail/VerifyDeleteModel.cs +++ b/src/Core/Auth/Models/Mail/VerifyDeleteModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.Auth.Models.Mail; diff --git a/src/Core/Auth/Models/Mail/VerifyEmailModel.cs b/src/Core/Auth/Models/Mail/VerifyEmailModel.cs index 703de7e045..ea1d7f3398 100644 --- a/src/Core/Auth/Models/Mail/VerifyEmailModel.cs +++ b/src/Core/Auth/Models/Mail/VerifyEmailModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Mail; namespace Bit.Core.Auth.Models.Mail; diff --git a/src/Core/Auth/Models/TwoFactorProvider.cs b/src/Core/Auth/Models/TwoFactorProvider.cs index 04ef4d7cb2..9152769425 100644 --- a/src/Core/Auth/Models/TwoFactorProvider.cs +++ b/src/Core/Auth/Models/TwoFactorProvider.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.Auth.Enums; using Fido2NetLib.Objects; diff --git a/src/Core/Auth/Repositories/IAuthRequestRepository.cs b/src/Core/Auth/Repositories/IAuthRequestRepository.cs index 3b01a452f9..7a66ad6e34 100644 --- a/src/Core/Auth/Repositories/IAuthRequestRepository.cs +++ b/src/Core/Auth/Repositories/IAuthRequestRepository.cs @@ -9,6 +9,13 @@ public interface IAuthRequestRepository : IRepository { Task DeleteExpiredAsync(TimeSpan userRequestExpiration, TimeSpan adminRequestExpiration, TimeSpan afterAdminApprovalExpiration); Task> GetManyByUserIdAsync(Guid userId); + /// + /// Gets all active pending auth requests for a user. Each auth request in the collection will be associated with a different + /// device. It will be the most current request for the device. + /// + /// UserId of the owner of the AuthRequests + /// a collection Auth request details or empty + Task> GetManyPendingAuthRequestByUserId(Guid userId); Task> GetManyPendingByOrganizationIdAsync(Guid organizationId); Task> GetManyAdminApprovalRequestsByManyIdsAsync(Guid organizationId, IEnumerable ids); Task UpdateManyAsync(IEnumerable authRequests); diff --git a/src/Core/Auth/Services/Implementations/EmergencyAccessService.cs b/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs similarity index 82% rename from src/Core/Auth/Services/Implementations/EmergencyAccessService.cs rename to src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs index 2418830ea7..4331179554 100644 --- a/src/Core/Auth/Services/Implementations/EmergencyAccessService.cs +++ b/src/Core/Auth/Services/EmergencyAccess/EmergencyAccessService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; @@ -58,38 +61,38 @@ public class EmergencyAccessService : IEmergencyAccessService _removeOrganizationUserCommand = removeOrganizationUserCommand; } - public async Task InviteAsync(User invitingUser, string email, EmergencyAccessType type, int waitTime) + public async Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime) { - if (!await _userService.CanAccessPremium(invitingUser)) + if (!await _userService.CanAccessPremium(grantorUser)) { throw new BadRequestException("Not a premium user."); } - if (type == EmergencyAccessType.Takeover && invitingUser.UsesKeyConnector) + if (accessType == EmergencyAccessType.Takeover && grantorUser.UsesKeyConnector) { throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector."); } var emergencyAccess = new EmergencyAccess { - GrantorId = invitingUser.Id, - Email = email.ToLowerInvariant(), + GrantorId = grantorUser.Id, + Email = emergencyContactEmail.ToLowerInvariant(), Status = EmergencyAccessStatusType.Invited, - Type = type, + Type = accessType, WaitTimeDays = waitTime, CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow, }; await _emergencyAccessRepository.CreateAsync(emergencyAccess); - await SendInviteAsync(emergencyAccess, NameOrEmail(invitingUser)); + await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser)); return emergencyAccess; } - public async Task GetAsync(Guid emergencyAccessId, Guid userId) + public async Task GetAsync(Guid emergencyAccessId, Guid grantorId) { - var emergencyAccess = await _emergencyAccessRepository.GetDetailsByIdGrantorIdAsync(emergencyAccessId, userId); + var emergencyAccess = await _emergencyAccessRepository.GetDetailsByIdGrantorIdAsync(emergencyAccessId, grantorId); if (emergencyAccess == null) { throw new BadRequestException("Emergency Access not valid."); @@ -98,19 +101,19 @@ public class EmergencyAccessService : IEmergencyAccessService return emergencyAccess; } - public async Task ResendInviteAsync(User invitingUser, Guid emergencyAccessId) + public async Task ResendInviteAsync(User grantorUser, Guid emergencyAccessId) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (emergencyAccess == null || emergencyAccess.GrantorId != invitingUser.Id || + if (emergencyAccess == null || emergencyAccess.GrantorId != grantorUser.Id || emergencyAccess.Status != EmergencyAccessStatusType.Invited) { throw new BadRequestException("Emergency Access not valid."); } - await SendInviteAsync(emergencyAccess, NameOrEmail(invitingUser)); + await SendInviteAsync(emergencyAccess, NameOrEmail(grantorUser)); } - public async Task AcceptUserAsync(Guid emergencyAccessId, User user, 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) @@ -123,7 +126,7 @@ public class EmergencyAccessService : IEmergencyAccessService throw new BadRequestException("Invalid token."); } - if (!data.IsValid(emergencyAccessId, user.Email)) + if (!data.IsValid(emergencyAccessId, granteeUser.Email)) { throw new BadRequestException("Invalid token."); } @@ -140,7 +143,7 @@ public class EmergencyAccessService : IEmergencyAccessService // TODO PM-21687 // Might not be reachable since the Tokenable.IsValid() does an email comparison if (string.IsNullOrWhiteSpace(emergencyAccess.Email) || - !emergencyAccess.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase)) + !emergencyAccess.Email.Equals(granteeUser.Email, StringComparison.InvariantCultureIgnoreCase)) { throw new BadRequestException("User email does not match invite."); } @@ -148,7 +151,7 @@ public class EmergencyAccessService : IEmergencyAccessService var granteeEmail = emergencyAccess.Email; emergencyAccess.Status = EmergencyAccessStatusType.Accepted; - emergencyAccess.GranteeId = user.Id; + emergencyAccess.GranteeId = granteeUser.Id; emergencyAccess.Email = null; var grantor = await userService.GetUserByIdAsync(emergencyAccess.GrantorId); @@ -172,16 +175,16 @@ public class EmergencyAccessService : IEmergencyAccessService await _emergencyAccessRepository.DeleteAsync(emergencyAccess); } - public async Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid confirmingUserId) + public async Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); if (emergencyAccess == null || emergencyAccess.Status != EmergencyAccessStatusType.Accepted || - emergencyAccess.GrantorId != confirmingUserId) + emergencyAccess.GrantorId != grantorId) { throw new BadRequestException("Emergency Access not valid."); } - var grantor = await _userRepository.GetByIdAsync(confirmingUserId); + var grantor = await _userRepository.GetByIdAsync(grantorId); if (emergencyAccess.Type == EmergencyAccessType.Takeover && grantor.UsesKeyConnector) { throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector."); @@ -198,14 +201,14 @@ public class EmergencyAccessService : IEmergencyAccessService return emergencyAccess; } - public async Task SaveAsync(EmergencyAccess emergencyAccess, User savingUser) + public async Task SaveAsync(EmergencyAccess emergencyAccess, User grantorUser) { - if (!await _userService.CanAccessPremium(savingUser)) + if (!await _userService.CanAccessPremium(grantorUser)) { throw new BadRequestException("Not a premium user."); } - if (emergencyAccess.GrantorId != savingUser.Id) + if (emergencyAccess.GrantorId != grantorUser.Id) { throw new BadRequestException("Emergency Access not valid."); } @@ -222,10 +225,11 @@ public class EmergencyAccessService : IEmergencyAccessService await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); } - public async Task InitiateAsync(Guid id, User initiatingUser) + // TODO PM-21687: rename this to something like InitiateRecoveryAsync, and something similar for Approve and Reject + public async Task InitiateAsync(Guid emergencyAccessId, User granteeUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); - if (emergencyAccess == null || emergencyAccess.GranteeId != initiatingUser.Id || + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); + if (emergencyAccess == null || emergencyAccess.GranteeId != granteeUser.Id || emergencyAccess.Status != EmergencyAccessStatusType.Confirmed) { throw new BadRequestException("Emergency Access not valid."); @@ -245,14 +249,14 @@ public class EmergencyAccessService : IEmergencyAccessService emergencyAccess.LastNotificationDate = now; await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); - await _mailService.SendEmergencyAccessRecoveryInitiated(emergencyAccess, NameOrEmail(initiatingUser), grantor.Email); + await _mailService.SendEmergencyAccessRecoveryInitiated(emergencyAccess, NameOrEmail(granteeUser), grantor.Email); } - public async Task ApproveAsync(Guid id, User approvingUser) + public async Task ApproveAsync(Guid emergencyAccessId, User grantorUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (emergencyAccess == null || emergencyAccess.GrantorId != approvingUser.Id || + if (emergencyAccess == null || emergencyAccess.GrantorId != grantorUser.Id || emergencyAccess.Status != EmergencyAccessStatusType.RecoveryInitiated) { throw new BadRequestException("Emergency Access not valid."); @@ -262,14 +266,14 @@ public class EmergencyAccessService : IEmergencyAccessService await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); var grantee = await _userRepository.GetByIdAsync(emergencyAccess.GranteeId.Value); - await _mailService.SendEmergencyAccessRecoveryApproved(emergencyAccess, NameOrEmail(approvingUser), grantee.Email); + await _mailService.SendEmergencyAccessRecoveryApproved(emergencyAccess, NameOrEmail(grantorUser), grantee.Email); } - public async Task RejectAsync(Guid id, User rejectingUser) + public async Task RejectAsync(Guid emergencyAccessId, User grantorUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (emergencyAccess == null || emergencyAccess.GrantorId != rejectingUser.Id || + if (emergencyAccess == null || emergencyAccess.GrantorId != grantorUser.Id || (emergencyAccess.Status != EmergencyAccessStatusType.RecoveryInitiated && emergencyAccess.Status != EmergencyAccessStatusType.RecoveryApproved)) { @@ -280,17 +284,17 @@ public class EmergencyAccessService : IEmergencyAccessService await _emergencyAccessRepository.ReplaceAsync(emergencyAccess); var grantee = await _userRepository.GetByIdAsync(emergencyAccess.GranteeId.Value); - await _mailService.SendEmergencyAccessRecoveryRejected(emergencyAccess, NameOrEmail(rejectingUser), grantee.Email); + await _mailService.SendEmergencyAccessRecoveryRejected(emergencyAccess, NameOrEmail(grantorUser), grantee.Email); } - public async Task> GetPoliciesAsync(Guid id, User requestingUser) + public async Task> GetPoliciesAsync(Guid emergencyAccessId, User granteeUser) { // TODO PM-21687 // Should we look up policies here or just verify the EmergencyAccess is correct // and handle policy logic else where? Should this be a query/Command? - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.Takeover)) + if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.Takeover)) { throw new BadRequestException("Emergency Access not valid."); } @@ -306,11 +310,12 @@ public class EmergencyAccessService : IEmergencyAccessService return policies; } - public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid id, User requestingUser) + // TODO PM-21687: rename this to something like InitiateRecoveryTakeoverAsync + public async Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.Takeover)) + if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.Takeover)) { throw new BadRequestException("Emergency Access not valid."); } @@ -326,11 +331,12 @@ public class EmergencyAccessService : IEmergencyAccessService return (emergencyAccess, grantor); } - public async Task PasswordAsync(Guid id, User requestingUser, string newMasterPasswordHash, string key) + // TODO PM-21687: rename this to something like FinishRecoveryTakeoverAsync + public async Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.Takeover)) + if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.Takeover)) { throw new BadRequestException("Emergency Access not valid."); } @@ -392,11 +398,11 @@ public class EmergencyAccessService : IEmergencyAccessService } } - public async Task ViewAsync(Guid id, User requestingUser) + public async Task ViewAsync(Guid emergencyAccessId, User granteeUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.View)) + if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.View)) { throw new BadRequestException("Emergency Access not valid."); } @@ -410,11 +416,11 @@ public class EmergencyAccessService : IEmergencyAccessService }; } - public async Task GetAttachmentDownloadAsync(Guid id, Guid cipherId, string attachmentId, User requestingUser) + public async Task GetAttachmentDownloadAsync(Guid emergencyAccessId, Guid cipherId, string attachmentId, User granteeUser) { - var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); - if (!IsValidRequest(emergencyAccess, requestingUser, EmergencyAccessType.View)) + if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.View)) { throw new BadRequestException("Emergency Access not valid."); } @@ -429,18 +435,19 @@ public class EmergencyAccessService : IEmergencyAccessService await _mailService.SendEmergencyAccessInviteEmailAsync(emergencyAccess, invitingUsersName, token); } + // TODO PM-21687: move this to the user entity -> User.GetNameOrEmail()? private static string NameOrEmail(User user) { return string.IsNullOrWhiteSpace(user.Name) ? user.Email : user.Name; } - /* * Checks if EmergencyAccess Object is null * Checks the requesting user is the same as the granteeUser (So we are checking for proper grantee action) * Status _must_ equal RecoveryApproved (This means the grantor has invited, the grantee has accepted, and the grantor has approved so the shared key exists but hasn't been exercised yet) * request type must equal the type of access requested (View or Takeover) */ + //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, User requestingUser, diff --git a/src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs b/src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs new file mode 100644 index 0000000000..de695bbd7d --- /dev/null +++ b/src/Core/Auth/Services/EmergencyAccess/IEmergencyAccessService.cs @@ -0,0 +1,147 @@ +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; +using Bit.Core.Enums; +using Bit.Core.Services; +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Auth.Services; + +public interface IEmergencyAccessService +{ + /// + /// Invites a user via email to become an emergency contact for the Grantor user. The Grantor must have a premium subscription. + /// the grantor user must not be a member of the organization that uses KeyConnector. + /// + /// The user initiating the emergency contact request + /// Emergency contact + /// 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); + /// + /// Sends an invite to the emergency contact associated with the emergency access id. + /// + /// The grantor. This must be the owner of the Emergency Access object + /// The Id of the emergency access being requested. + /// void + Task ResendInviteAsync(User grantorUser, Guid emergencyAccessId); + /// + /// A grantee user accepts the emergency contact request. This updates the emergency access status to be + /// "Accepted", this is the middle step before the grantor user confirms the request. + /// + /// Id of the emergency access object being acted on. + /// User being invited to be an emergency contact + /// the tokenable that was sent via email + /// service dependency + /// void + Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService); + /// + /// The creator of the emergency access request can delete the request. + /// + /// Id of the emergency access being acted on + /// Id of the owner trying to delete the emergency access request + /// void + Task DeleteAsync(Guid emergencyAccessId, Guid grantorId); + /// + /// The grantor user confirms the acceptance of the emergency contact request. This stores the encrypted key allowing the grantee + /// access based on the emergency access type. + /// + /// Id of the emergency access being acted on. + /// 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); + /// + /// Fetches an emergency access object. The grantor user must own the object being fetched. + /// + /// Id of emergency access object + /// Id of the owner of the emergency access object. + /// Details of the emergency access object + Task GetAsync(Guid emergencyAccessId, Guid grantorId); + /// + /// Updates the emergency access object. + /// + /// emergency access entity being updated + /// grantor user + /// void + Task SaveAsync(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. + /// + /// EmergencyAccess Id + /// grantee user + /// void + Task InitiateAsync(Guid emergencyAccessId, User granteeUser); + /// + /// Approves a recovery request. Sets the EmergencyAccess.Status to RecoveryApproved. + /// + /// emergency access id + /// grantor user + /// void + Task ApproveAsync(Guid emergencyAccessId, User grantorUser); + /// + /// Rejects a recovery request. Sets the EmergencyAccess.Status to Confirmed. This does not remove the emergency access entity. The + /// Grantee user can still initiate another recovery request. + /// + /// emergency access id + /// grantor user + /// void + Task RejectAsync(Guid emergencyAccessId, User grantorUser); + /// + /// This request is made by the Grantee user to fetch the policies for the Grantor User. + /// The Grantor User has to be the owner of the organization. + /// If the Grantor user has OrganizationUserType.Owner then the policies for the _Grantor_ user + /// are returned. This is used to ensure the password is of the proper complexity for the organization. + /// + /// EmergencyAccess.Id being acted on + /// User making the request, this is the Grantee + /// null if the GrantorUser is not an organization owner; A list of policies otherwise. + Task> GetPoliciesAsync(Guid emergencyAccessId, User granteeUser); + /// + /// Fetches the emergency access entity and grantor user. The grantor user is returned so the correct KDF configuration is + /// used for the new password. + /// + /// 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); + /// + /// Updates the grantor's password hash and updates the key for the EmergencyAccess entity. + /// + /// Emergency Access Id being acted on + /// user making the request + /// new password hash set by grantee user + /// new encrypted user key + /// void + Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key); + /// + /// sends a reminder email that there is a pending request for recovery. + /// + /// void + Task SendNotificationsAsync(); + /// + /// This handles the auto approval of recovery requests started in the method. + /// An email will be sent to the Grantee and the Grantor notifying each the recovery has been approved. + /// + /// void + Task HandleTimedOutRequestsAsync(); + /// + /// Fetched ciphers from the grantors account for the grantee to view. + /// + /// Emergency access entity being acted on + /// user requesting cipher items + /// ciphers associated with the emergency access request + Task ViewAsync(Guid emergencyAccessId, User granteeUser); + /// + /// Returns attachment if the grantee user has access to the cipher through the emergency access entity. + /// + /// EmergencyAccess entity being acted on + /// cipher entity containing the attachment + /// Attachment entity + /// user making the request + /// attachment response + Task GetAttachmentDownloadAsync(Guid emergencyAccessId, Guid cipherId, string attachmentId, User granteeUser); +} diff --git a/src/Core/Auth/Services/EmergencyAccess/readme.md b/src/Core/Auth/Services/EmergencyAccess/readme.md new file mode 100644 index 0000000000..e2bdec3916 --- /dev/null +++ b/src/Core/Auth/Services/EmergencyAccess/readme.md @@ -0,0 +1,95 @@ +# Emergency Access System +This system allows users to share their `User.Key` with other users using public key exchange. An emergency contact (a grantee user) can view or takeover (reset the password) of the grantor user. + +When an account is taken over all two factor methods are turned off and device verification is disabled. + +This system is affected by the Key Rotation feature. The `EmergencyAccess.KeyEncrypted` is the `Grantor.UserKey` encrypted by the `Grantee.PublicKey`. So if the `User.Key` is rotated then all `EmergencyAccess` entities will need to be updated. + +## Special Cases +Users who use `KeyConnector` are not able to allow `Takeover` of their accounts. However, they can allow `View`. + +When a grantee user _takes over_ a grantor user's account, the grantor user will be removed from all organizations where the grantor user is not the `OrganizationUserType.Owner`. A grantor user will not be removed from organizations if the `EmergencyAccessType` is `View`. The grantee user will only be able to `View` the grantor user's ciphers, and not any of the organization ciphers, if any exist. + +## Step 1. Invitation + +A grantor user invites another user to be their emergency contact, the grantee. This will create a new `EmergencyAccess` entity in the database with the `EmergencyAccessStatusType` set to `Invited`. +The `EmergencyAccess.KeyEncrypted` field is empty, and the `GranteeId` is `null` since the user being invited via email may not have an account yet. + +### code +```csharp +// creates entity. +Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime); +// resend email to the EmergencyAccess.Email. +Task ResendInviteAsync(User grantorUser, Guid emergencyAccessId); +``` + +## Step 2. Acceptance + +The grantee user receives an email they have been invited to be an emergency contact for a grantor user. + +At this point the grantee user can accept the request. This will set the `EmergencyAccess.GranteeId` to the `User.Id` of the grantee user. The `EmergencyAccess.Status` is set to `Accepted`. + +If the grantee user does not have an account then they can create an account and accept the invitation. + +### Code +```csharp +// accepts the request to be an emergency contact. +Task AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService); +``` + +## Step 3. Confirmation + +Once the grantee user has accepted, the `EmergencyAccess.GranteeId` allows the grantor user the ability to query for the `GranteeUser.PublicKey`. With the `Grantee.PublicKey`, the grantor on the client is able to safely encrypt their `User.Key` and save the encrypted string to the database. + +The `EmergencyAccess.Status` is set to `Confirmed`, and the `EmergencyAccess.KeyEncrypted` is set. + +### Code +```csharp +Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId); +``` + +## Step 4. Recovery Approval + +The grantee user can now exercise the ability to view or takeover the account. This is done by initiating the recovery. Initiating recovery has a time delay specified by `EmergencyAccess.WaitTime`. `WaitTime` is set in the initial invite. The grantor user can approve the request before the `WaitTime`, but they _cannot_ reject the request _after_ the `WaitTime` has completed. If the recovery request is not rejected then once the `WaitTime` has passed the grantee user will be able to access the emergency access entity. + +### Code +```csharp +// Initiates the recovery process; Will set EmergencyAccess.Status to RecoveryInitiated. +Task InitiateAsync(Guid id, User granteeUser); +// Approved the recovery request; Will set EmergencyAccess.Status to RecoveryApproved. +Task ApproveAsync(Guid id, User approvingUser); +// Rejects the recovery request; Will set EmergencyAccess.Status to Confirmed. +Task RejectAsync(Guid id, User rejectingUser); +// Automatically set the EmergencyAccess.Status to RecoveryApproved after WaitTime has passed. +Task HandleTimedOutRequestsAsync(); +``` + +## Step 5. Recovering the account + +Once the `EmergencyAccess.Status` is `RecoveryApproved` the grantee user is able to exercise their ability to view or takeover the grantor account. Viewing allows the grantee user to view the vault data of the grantor user. Takeover allows the grantee to change the password of the grantor user. + +### Takeover +`TakeoverAsync(Guid, User)` returns the grantor user object along with the `EmergencyAccess` entity. The grantor user object is required since to update the password the client needs access to the grantor kdf configuration. Once the password has been set in the `PasswordAsync(Guid, User, string, string)` the account has been successfully recovered. + +Taking over the account will change the password of the grantor user, empty the two factor array on the grantor user, and disable device verification. + +```csharp +// Takeover returns the grantor user and the emergency access entity. +Task<(EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser); +// Password sets the password for the grantor user. +Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key); +// Returns Ciphers the Grantee is allowed to view based on the EmergencyAccess status. +Task ViewAsync(Guid emergencyAccessId, User granteeUser); +// Returns downloadable cipher attachments based on the EmergencyAccess status. +Task GetAttachmentDownloadAsync(Guid emergencyAccessId, Guid cipherId, string attachmentId, User granteeUser); +``` + +## Optional steps + +The grantor user is able to delete an emergency contact at anytime, at any point in the recovery process. + +### Code +```csharp +// deletes the associated EmergencyAccess Entity +Task DeleteAsync(Guid emergencyAccessId, Guid grantorId); +``` diff --git a/src/Core/Auth/Services/IAuthRequestService.cs b/src/Core/Auth/Services/IAuthRequestService.cs index 4e057f0ccf..d81f6e7c8c 100644 --- a/src/Core/Auth/Services/IAuthRequestService.cs +++ b/src/Core/Auth/Services/IAuthRequestService.cs @@ -1,5 +1,9 @@ using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Exceptions; using Bit.Core.Auth.Models.Api.Request.AuthRequest; +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Settings; #nullable enable @@ -7,8 +11,41 @@ namespace Bit.Core.Auth.Services; public interface IAuthRequestService { - Task GetAuthRequestAsync(Guid id, Guid userId); - Task GetValidatedAuthRequestAsync(Guid id, string code); + /// + /// Fetches an authRequest by Id. Returns AuthRequest if AuthRequest.UserId mateches + /// userId. Returns null if the user doesn't match or if the AuthRequest is not found. + /// + /// Authrequest Id being fetched + /// user who owns AuthRequest + /// An AuthRequest or null + Task GetAuthRequestAsync(Guid authRequestId, Guid userId); + /// + /// Fetches the authrequest from the database with the id provided. Then checks + /// the accessCode against the AuthRequest.AccessCode from the database. accessCodes + /// must match the found authRequest, and the AuthRequest must not be expired. Expiration + /// is configured in + /// + /// AuthRequest being acted on + /// Access code of the authrequest, must match saved database value + /// A valid AuthRequest or null + Task GetValidatedAuthRequestAsync(Guid authRequestId, string accessCode); + /// + /// Validates and Creates an in the database, as well as pushes it through notifications services + /// + /// + /// This method can only be called inside of an HTTP call because of it's reliance on + /// Task CreateAuthRequestAsync(AuthRequestCreateRequestModel model); + /// + /// Updates the AuthRequest per the AuthRequestUpdateRequestModel context. This approves + /// or rejects the login request. + /// + /// AuthRequest being acted on. + /// User acting on AuthRequest + /// Update context for the AuthRequest + /// retuns an AuthRequest or throws an exception + /// Thows if the AuthRequest has already been Approved/Rejected + /// Throws if the AuthRequest as expired or the userId doesn't match + /// Throws if the device isn't associated with the UserId Task UpdateAuthRequestAsync(Guid authRequestId, Guid userId, AuthRequestUpdateRequestModel model); } diff --git a/src/Core/Auth/Services/IEmergencyAccessService.cs b/src/Core/Auth/Services/IEmergencyAccessService.cs deleted file mode 100644 index 6dd17151e6..0000000000 --- a/src/Core/Auth/Services/IEmergencyAccessService.cs +++ /dev/null @@ -1,40 +0,0 @@ -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; -using Bit.Core.Enums; -using Bit.Core.Services; -using Bit.Core.Vault.Models.Data; - -namespace Bit.Core.Auth.Services; - -public interface IEmergencyAccessService -{ - Task InviteAsync(User invitingUser, string email, EmergencyAccessType type, int waitTime); - Task ResendInviteAsync(User invitingUser, Guid emergencyAccessId); - Task AcceptUserAsync(Guid emergencyAccessId, User user, string token, IUserService userService); - Task DeleteAsync(Guid emergencyAccessId, Guid grantorId); - Task ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId); - Task GetAsync(Guid emergencyAccessId, Guid userId); - Task SaveAsync(EmergencyAccess emergencyAccess, User savingUser); - Task InitiateAsync(Guid id, User initiatingUser); - Task ApproveAsync(Guid id, User approvingUser); - Task RejectAsync(Guid id, User rejectingUser); - /// - /// This request is made by the Grantee user to fetch the policies for the Grantor User. - /// The Grantor User has to be the owner of the organization. - /// If the Grantor user has OrganizationUserType.Owner then the policies for the _Grantor_ user - /// are returned. - /// - /// EmergencyAccess.Id being acted on - /// User making the request, this is the Grantee - /// null if the GrantorUser is not an organization owner; A list of policies otherwise. - Task> GetPoliciesAsync(Guid id, User requestingUser); - Task<(EmergencyAccess, User)> TakeoverAsync(Guid id, User initiatingUser); - Task PasswordAsync(Guid id, User user, string newMasterPasswordHash, string key); - Task SendNotificationsAsync(); - Task HandleTimedOutRequestsAsync(); - Task ViewAsync(Guid id, User user); - Task GetAttachmentDownloadAsync(Guid id, Guid cipherId, string attachmentId, User user); -} diff --git a/src/Core/Auth/Services/ITwoFactorEmailService.cs b/src/Core/Auth/Services/ITwoFactorEmailService.cs new file mode 100644 index 0000000000..b0d0de6b01 --- /dev/null +++ b/src/Core/Auth/Services/ITwoFactorEmailService.cs @@ -0,0 +1,11 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.Services; + +public interface ITwoFactorEmailService +{ + Task SendTwoFactorEmailAsync(User user); + Task SendTwoFactorSetupEmailAsync(User user); + Task SendNewDeviceVerificationEmailAsync(User user); + Task VerifyTwoFactorTokenAsync(User user, string token); +} diff --git a/src/Core/Auth/Services/Implementations/AuthRequestService.cs b/src/Core/Auth/Services/Implementations/AuthRequestService.cs index 0fd1846d00..11682b524f 100644 --- a/src/Core/Auth/Services/Implementations/AuthRequestService.cs +++ b/src/Core/Auth/Services/Implementations/AuthRequestService.cs @@ -58,9 +58,9 @@ public class AuthRequestService : IAuthRequestService _logger = logger; } - public async Task GetAuthRequestAsync(Guid id, Guid userId) + public async Task GetAuthRequestAsync(Guid authRequestId, Guid userId) { - var authRequest = await _authRequestRepository.GetByIdAsync(id); + var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId); if (authRequest == null || authRequest.UserId != userId) { return null; @@ -69,10 +69,10 @@ public class AuthRequestService : IAuthRequestService return authRequest; } - public async Task GetValidatedAuthRequestAsync(Guid id, string code) + public async Task GetValidatedAuthRequestAsync(Guid authRequestId, string accessCode) { - var authRequest = await _authRequestRepository.GetByIdAsync(id); - if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, code)) + var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId); + if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, accessCode)) { return null; } @@ -85,12 +85,6 @@ public class AuthRequestService : IAuthRequestService return authRequest; } - /// - /// Validates and Creates an in the database, as well as pushes it through notifications services - /// - /// - /// This method can only be called inside of an HTTP call because of it's reliance on - /// public async Task CreateAuthRequestAsync(AuthRequestCreateRequestModel model) { if (!_currentContext.DeviceType.HasValue) diff --git a/src/Core/Auth/Services/Implementations/SsoConfigService.cs b/src/Core/Auth/Services/Implementations/SsoConfigService.cs index bf7e2d56fe..fe8d9bdd6e 100644 --- a/src/Core/Auth/Services/Implementations/SsoConfigService.cs +++ b/src/Core/Auth/Services/Implementations/SsoConfigService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; diff --git a/src/Core/Auth/Services/Implementations/TwoFactorEmailService.cs b/src/Core/Auth/Services/Implementations/TwoFactorEmailService.cs new file mode 100644 index 0000000000..cb26e46cd5 --- /dev/null +++ b/src/Core/Auth/Services/Implementations/TwoFactorEmailService.cs @@ -0,0 +1,119 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Bit.Core.Auth.Enums; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Core.Auth.Enums; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.Services; + +public class TwoFactorEmailService : ITwoFactorEmailService +{ + private readonly ICurrentContext _currentContext; + private readonly UserManager _userManager; + private readonly IMailService _mailService; + + public TwoFactorEmailService( + ICurrentContext currentContext, + IMailService mailService, + UserManager userManager + ) + { + _currentContext = currentContext; + _userManager = userManager; + _mailService = mailService; + } + + /// + /// Sends a two-factor email to the user with an OTP token for login + /// + /// The user to whom the email should be sent + /// Thrown if the user does not have an email for email 2FA + public async Task SendTwoFactorEmailAsync(User user) + { + await VerifyAndSendTwoFactorEmailAsync(user, TwoFactorEmailPurpose.Login); + } + + /// + /// Sends a two-factor email to the user with an OTP for setting up 2FA + /// + /// The user to whom the email should be sent + /// Thrown if the user does not have an email for email 2FA + public async Task SendTwoFactorSetupEmailAsync(User user) + { + await VerifyAndSendTwoFactorEmailAsync(user, TwoFactorEmailPurpose.Setup); + } + + /// + /// Sends a new device verification email to the user with an OTP token + /// + /// The user to whom the email should be sent + /// Thrown if the user is not provided + public async Task SendNewDeviceVerificationEmailAsync(User user) + { + ArgumentNullException.ThrowIfNull(user); + + var token = await _userManager.GenerateUserTokenAsync(user, TokenOptions.DefaultEmailProvider, + "otp:" + user.Email); + + var deviceType = _currentContext.DeviceType?.GetType().GetMember(_currentContext.DeviceType?.ToString()) + .FirstOrDefault()?.GetCustomAttribute()?.GetName() ?? "Unknown Browser"; + + await _mailService.SendTwoFactorEmailAsync( + user.Email, user.Email, token, _currentContext.IpAddress, deviceType, TwoFactorEmailPurpose.NewDeviceVerification); + } + + /// + /// Verifies the two-factor token for the specified user + /// + /// The user for whom the token should be verified + /// The token to verify + /// Thrown if the user does not have an email for email 2FA + public async Task VerifyTwoFactorTokenAsync(User user, string token) + { + var email = GetUserTwoFactorEmail(user); + return await _userManager.VerifyTwoFactorTokenAsync(user, + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email), token); + } + + /// + /// Sends a two-factor email with the specified purpose to the user only if they have 2FA email set up + /// + /// The user to whom the email should be sent + /// The purpose of the email + /// Thrown if the user does not have an email set up for 2FA + private async Task VerifyAndSendTwoFactorEmailAsync(User user, TwoFactorEmailPurpose purpose) + { + var email = GetUserTwoFactorEmail(user); + var token = await _userManager.GenerateTwoFactorTokenAsync(user, + CoreHelpers.CustomProviderName(TwoFactorProviderType.Email)); + + var deviceType = _currentContext.DeviceType?.GetType().GetMember(_currentContext.DeviceType?.ToString()) + .FirstOrDefault()?.GetCustomAttribute()?.GetName() ?? "Unknown Browser"; + + await _mailService.SendTwoFactorEmailAsync( + email, user.Email, token, _currentContext.IpAddress, deviceType, purpose); + } + + /// + /// Verifies the user has email 2FA and will return the email if present and throw otherwise. + /// + /// The user to check + /// The user's 2FA email address + /// + private string GetUserTwoFactorEmail(User user) + { + var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Email); + if (provider == null || provider.MetaData == null || !provider.MetaData.TryGetValue("Email", out var emailValue)) + { + throw new ArgumentNullException("No email."); + } + return ((string)emailValue).ToLowerInvariant(); + } +} diff --git a/src/Core/Auth/UserFeatures/PasswordValidation/PasswordValidationConstants.cs b/src/Core/Auth/UserFeatures/PasswordValidation/PasswordValidationConstants.cs new file mode 100644 index 0000000000..b0803cd3cd --- /dev/null +++ b/src/Core/Auth/UserFeatures/PasswordValidation/PasswordValidationConstants.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Auth.UserFeatures.PasswordValidation; + +public static class PasswordValidationConstants +{ + public const int PasswordHasherKdfIterations = 100000; +} diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index e721649dc9..991be2b764 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -1,9 +1,11 @@ -using Bit.Core.AdminConsole.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -11,9 +13,6 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Business; -using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; @@ -26,15 +25,12 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IGlobalSettings _globalSettings; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPolicyRepository _policyRepository; - private readonly IReferenceEventService _referenceEventService; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; private readonly IDataProtector _organizationServiceDataProtector; private readonly IDataProtector _providerServiceDataProtector; - private readonly ICurrentContext _currentContext; - private readonly IUserService _userService; private readonly IMailService _mailService; @@ -48,11 +44,9 @@ public class RegisterUserCommand : IRegisterUserCommand IGlobalSettings globalSettings, IOrganizationUserRepository organizationUserRepository, IPolicyRepository policyRepository, - IReferenceEventService referenceEventService, IDataProtectionProvider dataProtectionProvider, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, - ICurrentContext currentContext, IUserService userService, IMailService mailService, IValidateRedemptionTokenCommand validateRedemptionTokenCommand, @@ -62,14 +56,12 @@ public class RegisterUserCommand : IRegisterUserCommand _globalSettings = globalSettings; _organizationUserRepository = organizationUserRepository; _policyRepository = policyRepository; - _referenceEventService = referenceEventService; _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( "OrganizationServiceDataProtector"); _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _registrationEmailVerificationTokenDataFactory = registrationEmailVerificationTokenDataFactory; - _currentContext = currentContext; _userService = userService; _mailService = mailService; @@ -86,7 +78,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; @@ -119,12 +110,6 @@ public class RegisterUserCommand : IRegisterUserCommand sentWelcomeEmail = true; if (!string.IsNullOrEmpty(initiationPath)) { - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext) - { - SignupInitiationPath = initiationPath - }); - return result; } } @@ -134,8 +119,6 @@ public class RegisterUserCommand : IRegisterUserCommand { await _mailService.SendWelcomeEmailAsync(user); } - - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; @@ -263,10 +246,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext) - { - ReceiveMarketingEmails = tokenable.ReceiveMarketingEmails - }); } return result; @@ -285,7 +264,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; @@ -306,7 +284,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; @@ -325,7 +302,6 @@ public class RegisterUserCommand : IRegisterUserCommand if (result == IdentityResult.Success) { await _mailService.SendWelcomeEmailAsync(user); - await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext)); } return result; diff --git a/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs new file mode 100644 index 0000000000..1feadaf081 --- /dev/null +++ b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using Bit.Core.Identity; + +namespace Bit.Core.Auth.UserFeatures.SendAccess; + +public static class SendAccessClaimsPrincipalExtensions +{ + public static Guid GetSendId(this ClaimsPrincipal user) + { + ArgumentNullException.ThrowIfNull(user); + + var sendIdClaim = user.FindFirst(Claims.SendId) + ?? throw new InvalidOperationException("Send ID claim not found."); + + if (!Guid.TryParse(sendIdClaim.Value, out var sendGuid)) + { + throw new InvalidOperationException("Invalid Send ID claim value."); + } + + return sendGuid; + } +} diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs index 8ef586ab51..719ff9ce9d 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs @@ -91,7 +91,7 @@ public class TdeOffboardingPasswordCommand : ITdeOffboardingPasswordCommand user.MasterPasswordHint = hint; await _userRepository.ReplaceAsync(user); - await _eventService.LogUserEventAsync(user.Id, EventType.User_UpdatedTempPassword); + await _eventService.LogUserEventAsync(user.Id, EventType.User_TdeOffboardingPasswordSet); await _pushService.PushLogOutAsync(user.Id); return IdentityResult.Success; diff --git a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs index 8d4bd49e42..cc86d3d71d 100644 --- a/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs +++ b/src/Core/Auth/UserFeatures/TwoFactorAuth/TwoFactorIsEnabledQuery.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Repositories; diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 7731e04af2..53bd8bdba2 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -38,7 +38,6 @@ public static class UserServiceCollectionExtensions public static void AddUserKeyCommands(this IServiceCollection services, IGlobalSettings globalSettings) { - services.AddScoped(); services.AddScoped(); } diff --git a/src/Core/Auth/UserFeatures/WebAuthnLogin/ICreateWebAuthnLoginCredentialCommand.cs b/src/Core/Auth/UserFeatures/WebAuthnLogin/ICreateWebAuthnLoginCredentialCommand.cs index c25e226a32..61a573cb2d 100644 --- a/src/Core/Auth/UserFeatures/WebAuthnLogin/ICreateWebAuthnLoginCredentialCommand.cs +++ b/src/Core/Auth/UserFeatures/WebAuthnLogin/ICreateWebAuthnLoginCredentialCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Fido2NetLib; namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin; diff --git a/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs b/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs index 65c98dea3b..795fa95b9d 100644 --- a/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs +++ b/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; using Bit.Core.Utilities; diff --git a/src/Core/Billing/BillingException.cs b/src/Core/Billing/BillingException.cs index c2b1b9f457..1203a15f7b 100644 --- a/src/Core/Billing/BillingException.cs +++ b/src/Core/Billing/BillingException.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Billing; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Billing; public class BillingException( string response = null, diff --git a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs index ceb512a0e3..432a778762 100644 --- a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs +++ b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs @@ -1,4 +1,7 @@ -using Microsoft.Extensions.Caching.Distributed; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Billing.Caches.Implementations; diff --git a/src/Core/Billing/Commands/BaseBillingCommand.cs b/src/Core/Billing/Commands/BaseBillingCommand.cs new file mode 100644 index 0000000000..b3e938548d --- /dev/null +++ b/src/Core/Billing/Commands/BaseBillingCommand.cs @@ -0,0 +1,81 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Commands; + +using static StripeConstants; + +public abstract class BaseBillingCommand( + ILogger logger) +{ + protected string CommandName => GetType().Name; + + /// + /// Override this property to set a client-facing conflict response in the case a is thrown + /// during the command's execution. + /// + protected virtual Conflict? DefaultConflict => null; + + /// + /// Executes the provided function within a predefined execution context, handling any exceptions that occur during the process. + /// + /// The type of the successful result expected from the provided function. + /// A function that performs an operation and returns a . + /// A task that represents the operation. The result provides a which may indicate success or an error outcome. + protected async Task> HandleAsync( + Func>> function) + { + try + { + return await function(); + } + catch (StripeException stripeException) when (ErrorCodes.Get().Contains(stripeException.StripeError.Code)) + { + return stripeException.StripeError.Code switch + { + ErrorCodes.CustomerTaxLocationInvalid => + new BadRequest( + "Your location wasn't recognized. Please ensure your country and postal code are valid and try again."), + + ErrorCodes.PaymentMethodMicroDepositVerificationAttemptsExceeded => + new BadRequest( + "You have exceeded the number of allowed verification attempts. Please contact support for assistance."), + + ErrorCodes.PaymentMethodMicroDepositVerificationDescriptorCodeMismatch => + new BadRequest( + "The verification code you provided does not match the one sent to your bank account. Please try again."), + + ErrorCodes.PaymentMethodMicroDepositVerificationTimeout => + new BadRequest( + "Your bank account was not verified within the required time period. Please contact support for assistance."), + + ErrorCodes.TaxIdInvalid => + new BadRequest( + "The tax ID number you provided was invalid. Please try again or contact support for assistance."), + + _ => new Unhandled(stripeException) + }; + } + catch (ConflictException conflictException) + { + logger.LogError("{Command}: {Message}", CommandName, conflictException.Message); + return DefaultConflict != null ? + DefaultConflict : + new Unhandled(conflictException); + } + catch (StripeException stripeException) + { + logger.LogError(stripeException, + "{Command}: An error occurred while communicating with Stripe | Code = {Code}", CommandName, + stripeException.StripeError.Code); + return new Unhandled(stripeException); + } + catch (Exception exception) + { + logger.LogError(exception, "{Command}: An unknown error occurred during execution", CommandName); + return new Unhandled(exception); + } + } +} diff --git a/src/Core/Billing/Commands/BillingCommandResult.cs b/src/Core/Billing/Commands/BillingCommandResult.cs new file mode 100644 index 0000000000..3238ab4107 --- /dev/null +++ b/src/Core/Billing/Commands/BillingCommandResult.cs @@ -0,0 +1,37 @@ +#nullable enable +using OneOf; + +namespace Bit.Core.Billing.Commands; + +public record BadRequest(string Response); +public record Conflict(string Response); +public record Unhandled(Exception? Exception = null, string Response = "Something went wrong with your request. Please contact support for assistance."); + +/// +/// A union type representing the result of a billing command. +/// +/// Choices include: +/// +/// : Success +/// : Invalid input +/// : A known, but unresolvable issue +/// : An unknown issue +/// +/// +/// +/// The successful result type of the operation. +public class BillingCommandResult : OneOfBase +{ + private BillingCommandResult(OneOf input) : base(input) { } + + public static implicit operator BillingCommandResult(T output) => new(output); + public static implicit operator BillingCommandResult(BadRequest badRequest) => new(badRequest); + public static implicit operator BillingCommandResult(Conflict conflict) => new(conflict); + public static implicit operator BillingCommandResult(Unhandled unhandled) => new(unhandled); + + public Task TapAsync(Func f) => Match( + f, + _ => Task.CompletedTask, + _ => Task.CompletedTask, + _ => Task.CompletedTask); +} diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 28f4dea4b2..7b4cb3baed 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Billing.Constants; +using System.Reflection; + +namespace Bit.Core.Billing.Constants; public static class StripeConstants { @@ -36,6 +38,13 @@ public static class StripeConstants public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch"; public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout"; public const string TaxIdInvalid = "tax_id_invalid"; + + public static string[] Get() => + typeof(ErrorCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(fi => fi is { IsLiteral: true, IsInitOnly: false } && fi.FieldType == typeof(string)) + .Select(fi => (string)fi.GetValue(null)!) + .ToArray(); } public static class InvoiceStatus @@ -51,6 +60,8 @@ public static class StripeConstants public const string InvoiceApproved = "invoice_approved"; public const string OrganizationId = "organizationId"; public const string ProviderId = "providerId"; + public const string Region = "region"; + public const string RetiredBraintreeCustomerId = "btCustomerId_old"; public const string UserId = "userId"; } @@ -96,9 +107,22 @@ public static class StripeConstants public const string Reverse = "reverse"; } + public static class TaxIdType + { + public const string EUVAT = "eu_vat"; + public const string SpanishNIF = "es_cif"; + } + public static class ValidateTaxLocationTiming { public const string Deferred = "deferred"; public const string Immediately = "immediately"; } + + public static class MissingPaymentMethodBehaviorOptions + { + public const string CreateInvoice = "create_invoice"; + public const string Cancel = "cancel"; + public const string Pause = "pause"; + } } diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 5c7a42e9b8..39ee3ec1ec 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,10 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches.Implementations; using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Billing.Organizations.Services; +using Bit.Core.Billing.Payment; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; @@ -27,5 +31,15 @@ public static class ServiceCollectionExtensions services.AddLicenseServices(); services.AddPricingClient(); services.AddTransient(); + services.AddPaymentOperations(); + services.AddOrganizationLicenseCommandsQueries(); + services.AddTransient(); + } + + private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/Billing/Extensions/SubscriberExtensions.cs b/src/Core/Billing/Extensions/SubscriberExtensions.cs index e322ed7317..fc804de224 100644 --- a/src/Core/Billing/Extensions/SubscriberExtensions.cs +++ b/src/Core/Billing/Extensions/SubscriberExtensions.cs @@ -1,4 +1,8 @@ -using Bit.Core.Entities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Entities; namespace Bit.Core.Billing.Extensions; @@ -23,4 +27,14 @@ public static class SubscriberExtensions ? subscriberName : subscriberName[..30]; } + + public static ProductUsageType GetProductUsageType(this ISubscriber subscriber) + => subscriber switch + { + User => ProductUsageType.Personal, + Organization organization when organization.PlanType.GetProductTier() is ProductTierType.Free or ProductTierType.Families => ProductUsageType.Personal, + Organization => ProductUsageType.Business, + Provider => ProductUsageType.Business, + _ => throw new ArgumentOutOfRangeException(nameof(subscriber)) + }; } diff --git a/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs b/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs index 184d8dad23..9ac1ace156 100644 --- a/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs +++ b/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs @@ -1,4 +1,7 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Models.Business; diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index b3f2ab4ec9..678ac7f97e 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -1,4 +1,7 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using System.Security.Claims; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; diff --git a/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs index 2aaa5efdc1..5ad1a4a294 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs @@ -1,4 +1,7 @@ -using System.Globalization; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Globalization; using System.Security.Claims; using Bit.Core.Billing.Licenses.Models; using Bit.Core.Entities; diff --git a/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs index b31da9efbc..3392594f06 100644 --- a/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs +++ b/src/Core/Billing/Models/Api/Requests/Accounts/TrialSendVerificationEmailRequestModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models.Api.Request.Accounts; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Billing.Enums; namespace Bit.Core.Billing.Models.Api.Requests.Accounts; diff --git a/src/Core/Billing/Models/BillingCommandResult.cs b/src/Core/Billing/Models/BillingCommandResult.cs deleted file mode 100644 index 1b8eefe8df..0000000000 --- a/src/Core/Billing/Models/BillingCommandResult.cs +++ /dev/null @@ -1,36 +0,0 @@ -using OneOf; - -namespace Bit.Core.Billing.Models; - -public record BadRequest(string TranslationKey) -{ - public static BadRequest TaxIdNumberInvalid => new(BillingErrorTranslationKeys.TaxIdInvalid); - public static BadRequest TaxLocationInvalid => new(BillingErrorTranslationKeys.CustomerTaxLocationInvalid); - public static BadRequest UnknownTaxIdType => new(BillingErrorTranslationKeys.UnknownTaxIdType); -} - -public record Unhandled(string TranslationKey = BillingErrorTranslationKeys.UnhandledError); - -public class BillingCommandResult : OneOfBase -{ - private BillingCommandResult(OneOf input) : base(input) { } - - public static implicit operator BillingCommandResult(T output) => new(output); - public static implicit operator BillingCommandResult(BadRequest badRequest) => new(badRequest); - public static implicit operator BillingCommandResult(Unhandled unhandled) => new(unhandled); -} - -public static class BillingErrorTranslationKeys -{ - // "The tax ID number you provided was invalid. Please try again or contact support." - public const string TaxIdInvalid = "taxIdInvalid"; - - // "Your location wasn't recognized. Please ensure your country and postal code are valid and try again." - public const string CustomerTaxLocationInvalid = "customerTaxLocationInvalid"; - - // "Something went wrong with your request. Please contact support." - public const string UnhandledError = "unhandledBillingError"; - - // "We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support." - public const string UnknownTaxIdType = "unknownTaxIdType"; -} diff --git a/src/Core/Billing/Models/BillingHistoryInfo.cs b/src/Core/Billing/Models/BillingHistoryInfo.cs index 03017b9b4d..3114e22fdf 100644 --- a/src/Core/Billing/Models/BillingHistoryInfo.cs +++ b/src/Core/Billing/Models/BillingHistoryInfo.cs @@ -1,4 +1,7 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.Enums; using Stripe; diff --git a/src/Core/Billing/Models/BillingInfo.cs b/src/Core/Billing/Models/BillingInfo.cs index 9bdc042570..5b7f2484be 100644 --- a/src/Core/Billing/Models/BillingInfo.cs +++ b/src/Core/Billing/Models/BillingInfo.cs @@ -1,4 +1,7 @@ -using Bit.Core.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Enums; using Stripe; namespace Bit.Core.Billing.Models; diff --git a/src/Core/Models/Business/ILicense.cs b/src/Core/Billing/Models/Business/ILicense.cs similarity index 93% rename from src/Core/Models/Business/ILicense.cs rename to src/Core/Billing/Models/Business/ILicense.cs index b0e295bdd9..2727541847 100644 --- a/src/Core/Models/Business/ILicense.cs +++ b/src/Core/Billing/Models/Business/ILicense.cs @@ -1,6 +1,6 @@ using System.Security.Cryptography.X509Certificates; -namespace Bit.Core.Models.Business; +namespace Bit.Core.Billing.Models.Business; public interface ILicense { diff --git a/src/Core/Models/Business/UserLicense.cs b/src/Core/Billing/Models/Business/UserLicense.cs similarity index 94% rename from src/Core/Models/Business/UserLicense.cs rename to src/Core/Billing/Models/Business/UserLicense.cs index 797aa6692a..d13de17d47 100644 --- a/src/Core/Models/Business/UserLicense.cs +++ b/src/Core/Billing/Models/Business/UserLicense.cs @@ -1,15 +1,19 @@ -using System.Reflection; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Reflection; using System.Security.Claims; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json.Serialization; using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Services; +using Bit.Core.Models.Business; -namespace Bit.Core.Models.Business; +namespace Bit.Core.Billing.Models.Business; public class UserLicense : ILicense { @@ -19,7 +23,7 @@ public class UserLicense : ILicense public UserLicense(User user, SubscriptionInfo subscriptionInfo, ILicensingService licenseService, int? version = null) { - LicenseType = Enums.LicenseType.User; + LicenseType = Core.Enums.LicenseType.User; LicenseKey = user.LicenseKey; Id = user.Id; Name = user.Name; @@ -41,7 +45,7 @@ public class UserLicense : ILicense public UserLicense(User user, ILicensingService licenseService, int? version = null) { - LicenseType = Enums.LicenseType.User; + LicenseType = Core.Enums.LicenseType.User; LicenseKey = user.LicenseKey; Id = user.Id; Name = user.Name; @@ -97,7 +101,7 @@ public class UserLicense : ILicense ) )) .OrderBy(p => p.Name) - .Select(p => $"{p.Name}:{Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") + .Select(p => $"{p.Name}:{Core.Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") .Aggregate((c, n) => $"{c}|{n}"); data = $"license:user|{props}"; } diff --git a/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs b/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs index b97390dcc9..019edccd04 100644 --- a/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs +++ b/src/Core/Billing/Models/Mail/TrialInititaionVerifyEmail.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models.Mail; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Models.Mail; using Bit.Core.Billing.Enums; using Bit.Core.Enums; diff --git a/src/Core/Billing/Models/OffboardingSurveyResponse.cs b/src/Core/Billing/Models/OffboardingSurveyResponse.cs index cd966f40cc..0d55dcdc56 100644 --- a/src/Core/Billing/Models/OffboardingSurveyResponse.cs +++ b/src/Core/Billing/Models/OffboardingSurveyResponse.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Billing.Models; public class OffboardingSurveyResponse { diff --git a/src/Core/Billing/Models/PaymentMethod.cs b/src/Core/Billing/Models/PaymentMethod.cs index 2b8c59fa05..14ee79b714 100644 --- a/src/Core/Billing/Models/PaymentMethod.cs +++ b/src/Core/Billing/Models/PaymentMethod.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Tax.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Tax.Models; namespace Bit.Core.Billing.Models; diff --git a/src/Core/Billing/Models/PaymentSource.cs b/src/Core/Billing/Models/PaymentSource.cs index 44bbddc66b..130b0f71c4 100644 --- a/src/Core/Billing/Models/PaymentSource.cs +++ b/src/Core/Billing/Models/PaymentSource.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Extensions; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Extensions; using Bit.Core.Enums; namespace Bit.Core.Billing.Models; diff --git a/src/Core/Billing/Models/Sales/SubscriptionSetup.cs b/src/Core/Billing/Models/Sales/SubscriptionSetup.cs index 871a2920b1..5e891f75b6 100644 --- a/src/Core/Billing/Models/Sales/SubscriptionSetup.cs +++ b/src/Core/Billing/Models/Sales/SubscriptionSetup.cs @@ -10,6 +10,7 @@ public class SubscriptionSetup public required PasswordManager PasswordManagerOptions { get; set; } public SecretsManager? SecretsManagerOptions { get; set; } public bool SkipTrial = false; + public string? InitiationPath { get; set; } public class PasswordManager { diff --git a/src/Core/Billing/Models/StaticStore/Plan.cs b/src/Core/Billing/Models/StaticStore/Plan.cs index d710594f46..540ea76582 100644 --- a/src/Core/Billing/Models/StaticStore/Plan.cs +++ b/src/Core/Billing/Models/StaticStore/Plan.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Enums; namespace Bit.Core.Models.StaticStore; diff --git a/src/Core/Billing/Models/StaticStore/SponsoredPlan.cs b/src/Core/Billing/Models/StaticStore/SponsoredPlan.cs index d0d98159a8..840a652225 100644 --- a/src/Core/Billing/Models/StaticStore/SponsoredPlan.cs +++ b/src/Core/Billing/Models/StaticStore/SponsoredPlan.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Enums; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Enums; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations.OrganizationUsers; diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs similarity index 87% rename from src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs rename to src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs index ffeee39c07..fde95f2e70 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs +++ b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs @@ -1,16 +1,20 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; -namespace Bit.Core.OrganizationFeatures.OrganizationLicenses; +namespace Bit.Core.Billing.Organizations.Commands; + +public interface IUpdateOrganizationLicenseCommand +{ + Task UpdateLicenseAsync(SelfHostedOrganizationDetails selfHostedOrganization, + OrganizationLicense license, Organization? currentOrganizationUsingLicenseKey); +} public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseCommand { diff --git a/src/Core/Billing/Entities/OrganizationInstallation.cs b/src/Core/Billing/Organizations/Entities/OrganizationInstallation.cs similarity index 90% rename from src/Core/Billing/Entities/OrganizationInstallation.cs rename to src/Core/Billing/Organizations/Entities/OrganizationInstallation.cs index 4332afd44a..98930ae805 100644 --- a/src/Core/Billing/Entities/OrganizationInstallation.cs +++ b/src/Core/Billing/Organizations/Entities/OrganizationInstallation.cs @@ -1,9 +1,7 @@ using Bit.Core.Entities; using Bit.Core.Utilities; -namespace Bit.Core.Billing.Entities; - -#nullable enable +namespace Bit.Core.Billing.Organizations.Entities; public class OrganizationInstallation : ITableObject { diff --git a/src/Core/Models/Business/OrganizationLicense.cs b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs similarity index 97% rename from src/Core/Models/Business/OrganizationLicense.cs rename to src/Core/Billing/Organizations/Models/OrganizationLicense.cs index e8c04b1277..cd90cb517e 100644 --- a/src/Core/Models/Business/OrganizationLicense.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs @@ -1,4 +1,7 @@ -using System.Reflection; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Reflection; using System.Security.Claims; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -7,11 +10,13 @@ using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Licenses.Extensions; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Services; using Bit.Core.Enums; -using Bit.Core.Services; +using Bit.Core.Models.Business; using Bit.Core.Settings; -namespace Bit.Core.Models.Business; +namespace Bit.Core.Billing.Organizations.Models; public class OrganizationLicense : ILicense { @@ -51,7 +56,7 @@ public class OrganizationLicense : ILicense ILicensingService licenseService, int? version = null) { Version = version.GetValueOrDefault(CurrentLicenseFileVersion); // TODO: Remember to change the constant - LicenseType = Enums.LicenseType.Organization; + LicenseType = Core.Enums.LicenseType.Organization; LicenseKey = org.LicenseKey; InstallationId = installationId; Id = org.Id; @@ -121,7 +126,7 @@ public class OrganizationLicense : ILicense subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180)) { Refresh = DateTime.UtcNow.AddDays(30); - Expires = subscriptionInfo.Subscription.PeriodEndDate?.AddDays(Constants + Expires = subscriptionInfo.Subscription.PeriodEndDate?.AddDays(Core.Constants .OrganizationSelfHostSubscriptionGracePeriodDays); ExpirationWithoutGracePeriod = subscriptionInfo.Subscription.PeriodEndDate; } @@ -260,7 +265,7 @@ public class OrganizationLicense : ILicense !p.Name.Equals(nameof(UseAdminSponsoredFamilies)) && !p.Name.Equals(nameof(UseOrganizationDomains))) .OrderBy(p => p.Name) - .Select(p => $"{p.Name}:{Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") + .Select(p => $"{p.Name}:{Core.Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") .Aggregate((c, n) => $"{c}|{n}"); data = $"license:organization|{props}"; } @@ -312,7 +317,7 @@ public class OrganizationLicense : ILicense } var licenseType = claimsPrincipal.GetValue(nameof(LicenseType)); - if (licenseType != Enums.LicenseType.Organization) + if (licenseType != Core.Enums.LicenseType.Organization) { errorMessages.AppendLine("Premium licenses cannot be applied to an organization. " + "Upload this license from your personal account settings page."); @@ -393,7 +398,7 @@ public class OrganizationLicense : ILicense errorMessages.AppendLine("The license does not allow for on-premise hosting of organizations."); } - if (LicenseType != null && LicenseType != Enums.LicenseType.Organization) + if (LicenseType != null && LicenseType != Core.Enums.LicenseType.Organization) { errorMessages.AppendLine("Premium licenses cannot be applied to an organization. " + "Upload this license from your personal account settings page."); diff --git a/src/Core/Billing/Models/OrganizationMetadata.cs b/src/Core/Billing/Organizations/Models/OrganizationMetadata.cs similarity index 78% rename from src/Core/Billing/Models/OrganizationMetadata.cs rename to src/Core/Billing/Organizations/Models/OrganizationMetadata.cs index 41666949bf..2bcd213dbf 100644 --- a/src/Core/Billing/Models/OrganizationMetadata.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationMetadata.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Models; +namespace Bit.Core.Billing.Organizations.Models; public record OrganizationMetadata( bool IsEligibleForSelfHost, @@ -10,7 +10,8 @@ public record OrganizationMetadata( bool IsSubscriptionCanceled, DateTime? InvoiceDueDate, DateTime? InvoiceCreatedDate, - DateTime? SubPeriodEndDate) + DateTime? SubPeriodEndDate, + int OrganizationOccupiedSeats) { public static OrganizationMetadata Default => new OrganizationMetadata( false, @@ -22,5 +23,6 @@ public record OrganizationMetadata( false, null, null, - null); + null, + 0); } diff --git a/src/Core/Billing/Models/Sales/OrganizationSale.cs b/src/Core/Billing/Organizations/Models/OrganizationSale.cs similarity index 95% rename from src/Core/Billing/Models/Sales/OrganizationSale.cs rename to src/Core/Billing/Organizations/Models/OrganizationSale.cs index 78ad26871b..f1f3a636b7 100644 --- a/src/Core/Billing/Models/Sales/OrganizationSale.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationSale.cs @@ -1,11 +1,11 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Tax.Models; using Bit.Core.Models.Business; -namespace Bit.Core.Billing.Models.Sales; - -#nullable enable +namespace Bit.Core.Billing.Organizations.Models; public class OrganizationSale { @@ -34,6 +34,7 @@ public class OrganizationSale var subscriptionSetup = GetSubscriptionSetup(signup); subscriptionSetup.SkipTrial = signup.SkipTrial; + subscriptionSetup.InitiationPath = signup.InitiationPath; return new OrganizationSale { diff --git a/src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs b/src/Core/Billing/Organizations/Models/OrganizationWarnings.cs similarity index 90% rename from src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs rename to src/Core/Billing/Organizations/Models/OrganizationWarnings.cs index e124bdc318..4507c84083 100644 --- a/src/Api/Billing/Models/Responses/Organizations/OrganizationWarningsResponse.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationWarnings.cs @@ -1,7 +1,6 @@ -#nullable enable -namespace Bit.Api.Billing.Models.Responses.Organizations; +namespace Bit.Core.Billing.Organizations.Models; -public record OrganizationWarningsResponse +public record OrganizationWarnings { public FreeTrialWarning? FreeTrial { get; set; } public InactiveSubscriptionWarning? InactiveSubscription { get; set; } diff --git a/src/Core/Billing/Models/Business/SponsorOrganizationSubscriptionUpdate.cs b/src/Core/Billing/Organizations/Models/SponsorOrganizationSubscriptionUpdate.cs similarity index 94% rename from src/Core/Billing/Models/Business/SponsorOrganizationSubscriptionUpdate.cs rename to src/Core/Billing/Organizations/Models/SponsorOrganizationSubscriptionUpdate.cs index 830105e373..ee603c67e0 100644 --- a/src/Core/Billing/Models/Business/SponsorOrganizationSubscriptionUpdate.cs +++ b/src/Core/Billing/Organizations/Models/SponsorOrganizationSubscriptionUpdate.cs @@ -1,7 +1,10 @@ -using Bit.Core.Models.Business; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Models.Business; using Stripe; -namespace Bit.Core.Billing.Models.Business; +namespace Bit.Core.Billing.Organizations.Models; public class SponsorOrganizationSubscriptionUpdate : SubscriptionUpdate { diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs b/src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs similarity index 79% rename from src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs rename to src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs index 44edde1495..f00bc00356 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetCloudOrganizationLicenseQuery.cs @@ -1,15 +1,25 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.Platform.Installations; using Bit.Core.Services; -namespace Bit.Core.OrganizationFeatures.OrganizationLicenses; +namespace Bit.Core.Billing.Organizations.Queries; -public class CloudGetOrganizationLicenseQuery : ICloudGetOrganizationLicenseQuery +public interface IGetCloudOrganizationLicenseQuery +{ + Task GetLicenseAsync(Organization organization, Guid installationId, + int? version = null); +} + +public class GetCloudOrganizationLicenseQuery : IGetCloudOrganizationLicenseQuery { private readonly IInstallationRepository _installationRepository; private readonly IPaymentService _paymentService; @@ -17,7 +27,7 @@ public class CloudGetOrganizationLicenseQuery : ICloudGetOrganizationLicenseQuer private readonly IProviderRepository _providerRepository; private readonly IFeatureService _featureService; - public CloudGetOrganizationLicenseQuery( + public GetCloudOrganizationLicenseQuery( IInstallationRepository installationRepository, IPaymentService paymentService, ILicensingService licensingService, diff --git a/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs similarity index 51% rename from src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs rename to src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index f6a0e5b1e6..a46d7483e7 100644 --- a/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -1,41 +1,44 @@ // ReSharper disable InconsistentNaming -#nullable enable - -using Bit.Api.Billing.Models.Responses.Organizations; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Services; using Stripe; -using FreeTrialWarning = Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.FreeTrialWarning; +using FreeTrialWarning = Bit.Core.Billing.Organizations.Models.OrganizationWarnings.FreeTrialWarning; using InactiveSubscriptionWarning = - Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.InactiveSubscriptionWarning; + Bit.Core.Billing.Organizations.Models.OrganizationWarnings.InactiveSubscriptionWarning; using ResellerRenewalWarning = - Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.ResellerRenewalWarning; + Bit.Core.Billing.Organizations.Models.OrganizationWarnings.ResellerRenewalWarning; -namespace Bit.Api.Billing.Queries.Organizations; +namespace Bit.Core.Billing.Organizations.Queries; -public interface IOrganizationWarningsQuery +using static StripeConstants; + +public interface IGetOrganizationWarningsQuery { - Task Run( + Task Run( Organization organization); } -public class OrganizationWarningsQuery( +public class GetOrganizationWarningsQuery( ICurrentContext currentContext, IProviderRepository providerRepository, + ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService) : IOrganizationWarningsQuery + ISubscriberService subscriberService) : IGetOrganizationWarningsQuery { - public async Task Run( + public async Task Run( Organization organization) { - var response = new OrganizationWarningsResponse(); + var response = new OrganizationWarnings(); var subscription = await subscriberService.GetSubscription(organization, @@ -68,7 +71,7 @@ public class OrganizationWarningsQuery( if (subscription is not { - Status: StripeConstants.SubscriptionStatus.Trialing, + Status: SubscriptionStatus.Trialing, TrialEnd: not null, Customer: not null }) @@ -78,10 +81,13 @@ public class OrganizationWarningsQuery( var customer = subscription.Customer; + var hasUnverifiedBankAccount = await HasUnverifiedBankAccount(organization); + var hasPaymentMethod = !string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || !string.IsNullOrEmpty(customer.DefaultSourceId) || - customer.Metadata.ContainsKey(StripeConstants.MetadataKeys.BraintreeCustomerId); + hasUnverifiedBankAccount || + customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId); if (hasPaymentMethod) { @@ -100,35 +106,58 @@ public class OrganizationWarningsQuery( Provider? provider, Subscription subscription) { - if (organization.Enabled || - subscription.Status is not StripeConstants.SubscriptionStatus.Unpaid - and not StripeConstants.SubscriptionStatus.Canceled) - { - return null; - } + var isOrganizationOwner = await currentContext.OrganizationOwner(organization.Id); - if (provider != null) + switch (organization.Enabled) { - return new InactiveSubscriptionWarning { Resolution = "contact_provider" }; - } - - if (await currentContext.OrganizationOwner(organization.Id)) - { - return subscription.Status switch - { - StripeConstants.SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning + // Member of an enabled, trialing organization. + case true when subscription.Status is SubscriptionStatus.Trialing: { - Resolution = "add_payment_method" - }, - StripeConstants.SubscriptionStatus.Canceled => new InactiveSubscriptionWarning - { - Resolution = "resubscribe" - }, - _ => null - }; - } + var hasUnverifiedBankAccount = await HasUnverifiedBankAccount(organization); - return new InactiveSubscriptionWarning { Resolution = "contact_owner" }; + var hasPaymentMethod = + !string.IsNullOrEmpty(subscription.Customer.InvoiceSettings.DefaultPaymentMethodId) || + !string.IsNullOrEmpty(subscription.Customer.DefaultSourceId) || + hasUnverifiedBankAccount || + subscription.Customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId); + + // If this member is the owner and there's no payment method on file, ask them to add one. + return isOrganizationOwner && !hasPaymentMethod + ? new InactiveSubscriptionWarning { Resolution = "add_payment_method_optional_trial" } + : null; + } + // Member of disabled and unpaid or canceled organization. + case false when subscription.Status is SubscriptionStatus.Unpaid or SubscriptionStatus.Canceled: + { + // If the organization is managed by a provider, return a warning asking them to contact the provider. + if (provider != null) + { + return new InactiveSubscriptionWarning { Resolution = "contact_provider" }; + } + + /* If the organization is not managed by a provider and this user is the owner, return an action warning based + on the subscription status. */ + if (isOrganizationOwner) + { + return subscription.Status switch + { + SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning + { + Resolution = "add_payment_method" + }, + SubscriptionStatus.Canceled => new InactiveSubscriptionWarning + { + Resolution = "resubscribe" + }, + _ => null + }; + } + + // Otherwise, this member is not the owner, and we need to ask them to contact the owner. + return new InactiveSubscriptionWarning { Resolution = "contact_owner" }; + } + default: return null; + } } private async Task GetResellerRenewalWarning( @@ -143,7 +172,7 @@ public class OrganizationWarningsQuery( return null; } - if (subscription.CollectionMethod != StripeConstants.CollectionMethod.SendInvoice) + if (subscription.CollectionMethod != CollectionMethod.SendInvoice) { return null; } @@ -153,8 +182,8 @@ public class OrganizationWarningsQuery( // ReSharper disable once ConvertIfStatementToSwitchStatement if (subscription is { - Status: StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active, - LatestInvoice: null or { Status: StripeConstants.InvoiceStatus.Paid } + Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active, + LatestInvoice: null or { Status: InvoiceStatus.Paid } } && (subscription.CurrentPeriodEnd - now).TotalDays <= 14) { return new ResellerRenewalWarning @@ -169,8 +198,8 @@ public class OrganizationWarningsQuery( if (subscription is { - Status: StripeConstants.SubscriptionStatus.Active, - LatestInvoice: { Status: StripeConstants.InvoiceStatus.Open, DueDate: not null } + Status: SubscriptionStatus.Active, + LatestInvoice: { Status: InvoiceStatus.Open, DueDate: not null } } && subscription.LatestInvoice.DueDate > now) { return new ResellerRenewalWarning @@ -185,7 +214,7 @@ public class OrganizationWarningsQuery( } // ReSharper disable once InvertIf - if (subscription.Status == StripeConstants.SubscriptionStatus.PastDue) + if (subscription.Status == SubscriptionStatus.PastDue) { var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions { @@ -211,4 +240,22 @@ public class OrganizationWarningsQuery( return null; } + + private async Task HasUnverifiedBankAccount( + Organization organization) + { + var setupIntentId = await setupIntentCache.Get(organization.Id); + + if (string.IsNullOrEmpty(setupIntentId)) + { + return false; + } + + var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + { + Expand = ["payment_method"] + }); + + return setupIntent.IsUnverifiedBankAccount(); + } } diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/SelfHosted/SelfHostedGetOrganizationLicenseQuery.cs b/src/Core/Billing/Organizations/Queries/GetSelfHostedOrganizationLicenseQuery.cs similarity index 75% rename from src/Core/OrganizationFeatures/OrganizationLicenses/SelfHosted/SelfHostedGetOrganizationLicenseQuery.cs rename to src/Core/Billing/Organizations/Queries/GetSelfHostedOrganizationLicenseQuery.cs index 89ea53fc20..ad6c2a2cdf 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/SelfHosted/SelfHostedGetOrganizationLicenseQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetSelfHostedOrganizationLicenseQuery.cs @@ -1,22 +1,29 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Api.OrganizationLicenses; -using Bit.Core.Models.Business; using Bit.Core.Models.OrganizationConnectionConfigs; -using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.Extensions.Logging; -namespace Bit.Core.OrganizationFeatures.OrganizationLicenses; +namespace Bit.Core.Billing.Organizations.Queries; -public class SelfHostedGetOrganizationLicenseQuery : BaseIdentityClientService, ISelfHostedGetOrganizationLicenseQuery +public interface IGetSelfHostedOrganizationLicenseQuery +{ + Task GetLicenseAsync(Organization organization, OrganizationConnection billingSyncConnection); +} + +public class GetSelfHostedOrganizationLicenseQuery : BaseIdentityClientService, IGetSelfHostedOrganizationLicenseQuery { private readonly IGlobalSettings _globalSettings; - public SelfHostedGetOrganizationLicenseQuery(IHttpClientFactory httpFactory, IGlobalSettings globalSettings, ILogger logger, ICurrentContext currentContext) + public GetSelfHostedOrganizationLicenseQuery(IHttpClientFactory httpFactory, IGlobalSettings globalSettings, ILogger logger, ICurrentContext currentContext) : base( httpFactory, globalSettings.Installation.ApiUri, diff --git a/src/Core/Billing/Repositories/IOrganizationInstallationRepository.cs b/src/Core/Billing/Organizations/Repositories/IOrganizationInstallationRepository.cs similarity index 74% rename from src/Core/Billing/Repositories/IOrganizationInstallationRepository.cs rename to src/Core/Billing/Organizations/Repositories/IOrganizationInstallationRepository.cs index 05710d3966..cd96ab747e 100644 --- a/src/Core/Billing/Repositories/IOrganizationInstallationRepository.cs +++ b/src/Core/Billing/Organizations/Repositories/IOrganizationInstallationRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Billing.Entities; +using Bit.Core.Billing.Organizations.Entities; using Bit.Core.Repositories; -namespace Bit.Core.Billing.Repositories; +namespace Bit.Core.Billing.Organizations.Repositories; public interface IOrganizationInstallationRepository : IRepository { diff --git a/src/Core/Billing/Services/IOrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs similarity index 69% rename from src/Core/Billing/Services/IOrganizationBillingService.cs rename to src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs index 5f7d33f118..d34bd86e7b 100644 --- a/src/Core/Billing/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs @@ -1,11 +1,10 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Tax.Models; -namespace Bit.Core.Billing.Services; - -#nullable enable +namespace Bit.Core.Billing.Organizations.Services; public interface IOrganizationBillingService { @@ -46,4 +45,15 @@ public interface IOrganizationBillingService Organization organization, TokenizedPaymentSource tokenizedPaymentSource, TaxInformation taxInformation); + + /// + /// Updates the subscription with new plan frequencies and changes the collection method to charge_automatically if a valid payment method exists. + /// Validates that the customer has a payment method attached before switching to automatic charging. + /// Handles both Password Manager and Secrets Manager subscription items separately to ensure billing interval compatibility. + /// + /// The Organization whose subscription to update. + /// The Stripe price/plan for the new Password Manager and secrets manager. + /// Thrown when the is . + /// Thrown when no payment method is found for the customer, no plan IDs are provided, or subscription update fails. + Task UpdateSubscriptionPlanFrequency(Organization organization, PlanType newPlanType); } diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs similarity index 80% rename from src/Core/Billing/Services/Implementations/OrganizationBillingService.cs rename to src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 95df34dfd4..f32e835dbf 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -5,7 +5,9 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; @@ -16,14 +18,11 @@ using Bit.Core.Settings; using Braintree; using Microsoft.Extensions.Logging; using Stripe; - using static Bit.Core.Billing.Utilities; using Customer = Stripe.Customer; using Subscription = Stripe.Subscription; -namespace Bit.Core.Billing.Services.Implementations; - -#nullable enable +namespace Bit.Core.Billing.Organizations.Services; public class OrganizationBillingService( IBraintreeGateway braintreeGateway, @@ -77,13 +76,14 @@ public class OrganizationBillingService( var isEligibleForSelfHost = await IsEligibleForSelfHostAsync(organization); var isManaged = organization.Status == OrganizationStatusType.Managed; - + var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { return OrganizationMetadata.Default with { IsEligibleForSelfHost = isEligibleForSelfHost, - IsManaged = isManaged + IsManaged = isManaged, + OrganizationOccupiedSeats = orgOccupiedSeats.Total }; } @@ -117,7 +117,8 @@ public class OrganizationBillingService( subscription.Status == StripeConstants.SubscriptionStatus.Canceled, invoice?.DueDate, invoice?.Created, - subscription.CurrentPeriodEnd); + subscription.CurrentPeriodEnd, + orgOccupiedSeats.Total); } public async Task @@ -144,6 +145,55 @@ public class OrganizationBillingService( { await subscriberService.UpdatePaymentSource(organization, tokenizedPaymentSource); await subscriberService.UpdateTaxInformation(organization, taxInformation); + await UpdateMissingPaymentMethodBehaviourAsync(organization); + } + } + + public async Task UpdateSubscriptionPlanFrequency( + Organization organization, PlanType newPlanType) + { + ArgumentNullException.ThrowIfNull(organization); + + var subscription = await subscriberService.GetSubscriptionOrThrow(organization); + var subscriptionItems = subscription.Items.Data; + + var newPlan = await pricingClient.GetPlanOrThrow(newPlanType); + var oldPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + // Build the subscription update options + var subscriptionItemOptions = new List(); + foreach (var item in subscriptionItems) + { + var subscriptionItemOption = new SubscriptionItemOptions + { + Id = item.Id, + Quantity = item.Quantity, + Price = item.Price.Id == oldPlan.SecretsManager.StripeSeatPlanId ? newPlan.SecretsManager.StripeSeatPlanId : newPlan.PasswordManager.StripeSeatPlanId + }; + + subscriptionItemOptions.Add(subscriptionItemOption); + } + var updateOptions = new SubscriptionUpdateOptions + { + Items = subscriptionItemOptions, + ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations + }; + + try + { + // Update the subscription in Stripe + await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, updateOptions); + organization.PlanType = newPlan.Type; + await organizationRepository.ReplaceAsync(organization); + } + catch (StripeException stripeException) + { + logger.LogError(stripeException, "Failed to update subscription plan for subscriber ({SubscriberID}): {Error}", + organization.Id, stripeException.Message); + + throw new BillingException( + message: "An error occurred while updating the subscription plan", + innerException: stripeException); } } @@ -244,12 +294,23 @@ public class OrganizationBillingService( organization.Id, customerSetup.TaxInformation.Country, customerSetup.TaxInformation.TaxId); + + throw new BadRequestException("billingTaxIdTypeInferenceError"); } customerCreateOptions.TaxIdData = [ new() { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId } ]; + + if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) + { + customerCreateOptions.TaxIdData.Add(new CustomerTaxIdDataOptions + { + Type = StripeConstants.TaxIdType.EUVAT, + Value = $"ES{customerSetup.TaxInformation.TaxId}" + }); + } } var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource; @@ -407,16 +468,33 @@ public class OrganizationBillingService( Items = subscriptionItemOptionsList, Metadata = new Dictionary { - ["organizationId"] = organizationId.ToString() + ["organizationId"] = organizationId.ToString(), + ["trialInitiationPath"] = !string.IsNullOrEmpty(subscriptionSetup.InitiationPath) && + subscriptionSetup.InitiationPath.Contains("trial from marketing website") + ? "marketing-initiated" + : "product-initiated" }, OffSession = true, TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays }; + // Only set trial_settings.end_behavior.missing_payment_method to "cancel" if there is no payment method + if (string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) && + !customer.Metadata.ContainsKey(BraintreeCustomerIdKey)) + { + subscriptionCreateOptions.TrialSettings = new SubscriptionTrialSettingsOptions + { + EndBehavior = new SubscriptionTrialSettingsEndBehaviorOptions + { + MissingPaymentMethod = "cancel" + } + }; + } + var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); - if (setNonUSBusinessUseToReverseCharge) + if (setNonUSBusinessUseToReverseCharge && customer.HasBillingLocation()) { subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; } @@ -516,5 +594,24 @@ public class OrganizationBillingService( return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); } + private async Task UpdateMissingPaymentMethodBehaviourAsync(Organization organization) + { + var subscription = await subscriberService.GetSubscriptionOrThrow(organization); + if (subscription.TrialSettings?.EndBehavior?.MissingPaymentMethod == StripeConstants.MissingPaymentMethodBehaviorOptions.Cancel) + { + var options = new SubscriptionUpdateOptions + { + TrialSettings = new SubscriptionTrialSettingsOptions + { + EndBehavior = new SubscriptionTrialSettingsEndBehaviorOptions + { + MissingPaymentMethod = StripeConstants.MissingPaymentMethodBehaviorOptions.CreateInvoice + } + } + }; + await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, options); + } + } + #endregion } diff --git a/src/Core/Billing/Payment/Clients/BitPayClient.cs b/src/Core/Billing/Payment/Clients/BitPayClient.cs new file mode 100644 index 0000000000..2cb8fb66ef --- /dev/null +++ b/src/Core/Billing/Payment/Clients/BitPayClient.cs @@ -0,0 +1,24 @@ +using Bit.Core.Settings; +using BitPayLight; +using BitPayLight.Models.Invoice; + +namespace Bit.Core.Billing.Payment.Clients; + +public interface IBitPayClient +{ + Task GetInvoice(string invoiceId); + Task CreateInvoice(Invoice invoice); +} + +public class BitPayClient( + GlobalSettings globalSettings) : IBitPayClient +{ + private readonly BitPay _bitPay = new( + globalSettings.BitPay.Token, globalSettings.BitPay.Production ? Env.Prod : Env.Test); + + public Task GetInvoice(string invoiceId) + => _bitPay.GetInvoice(invoiceId); + + public Task CreateInvoice(Invoice invoice) + => _bitPay.CreateInvoice(invoice); +} diff --git a/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs b/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs new file mode 100644 index 0000000000..a86f0e3ada --- /dev/null +++ b/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs @@ -0,0 +1,60 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Payment.Clients; +using Bit.Core.Entities; +using Bit.Core.Settings; +using BitPayLight.Models.Invoice; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Billing.Payment.Commands; + +public interface ICreateBitPayInvoiceForCreditCommand +{ + Task> Run( + ISubscriber subscriber, + decimal amount, + string redirectUrl); +} + +public class CreateBitPayInvoiceForCreditCommand( + IBitPayClient bitPayClient, + GlobalSettings globalSettings, + ILogger logger) : BaseBillingCommand(logger), ICreateBitPayInvoiceForCreditCommand +{ + protected override Conflict DefaultConflict => new("We had a problem applying your account credit. Please contact support for assistance."); + + public Task> Run( + ISubscriber subscriber, + decimal amount, + string redirectUrl) => HandleAsync(async () => + { + var (name, email, posData) = GetSubscriberInformation(subscriber); + + var invoice = new Invoice + { + Buyer = new Buyer { Email = email, Name = name }, + Currency = "USD", + ExtendedNotifications = true, + FullNotifications = true, + ItemDesc = "Bitwarden", + NotificationUrl = globalSettings.BitPay.NotificationUrl, + PosData = posData, + Price = Convert.ToDouble(amount), + RedirectUrl = redirectUrl + }; + + var created = await bitPayClient.CreateInvoice(invoice); + return created.Url; + }); + + private static (string? Name, string? Email, string POSData) GetSubscriberInformation( + ISubscriber subscriber) => subscriber switch + { + User user => (user.Email, user.Email, $"userId:{user.Id},accountCredit:1"), + Organization organization => (organization.Name, organization.BillingEmail, + $"organizationId:{organization.Id},accountCredit:1"), + Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},accountCredit:1"), + _ => throw new ArgumentOutOfRangeException(nameof(subscriber)) + }; +} diff --git a/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs new file mode 100644 index 0000000000..fdf519523a --- /dev/null +++ b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs @@ -0,0 +1,141 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Payment.Commands; + +public interface IUpdateBillingAddressCommand +{ + Task> Run( + ISubscriber subscriber, + BillingAddress billingAddress); +} + +public class UpdateBillingAddressCommand( + ILogger logger, + ISubscriberService subscriberService, + IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IUpdateBillingAddressCommand +{ + protected override Conflict DefaultConflict => + new("We had a problem updating your billing address. Please contact support for assistance."); + + public Task> Run( + ISubscriber subscriber, + BillingAddress billingAddress) => HandleAsync(async () => + { + if (string.IsNullOrEmpty(subscriber.GatewayCustomerId)) + { + await subscriberService.CreateStripeCustomer(subscriber); + } + + return subscriber.GetProductUsageType() switch + { + ProductUsageType.Personal => await UpdatePersonalBillingAddressAsync(subscriber, billingAddress), + ProductUsageType.Business => await UpdateBusinessBillingAddressAsync(subscriber, billingAddress) + }; + }); + + private async Task> UpdatePersonalBillingAddressAsync( + ISubscriber subscriber, + BillingAddress billingAddress) + { + var customer = + await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, + new CustomerUpdateOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode, + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + State = billingAddress.State + }, + Expand = ["subscriptions"] + }); + + await EnableAutomaticTaxAsync(subscriber, customer); + + return BillingAddress.From(customer.Address); + } + + private async Task> UpdateBusinessBillingAddressAsync( + ISubscriber subscriber, + BillingAddress billingAddress) + { + var customer = + await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, + new CustomerUpdateOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode, + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + State = billingAddress.State + }, + Expand = ["subscriptions", "tax_ids"], + TaxExempt = billingAddress.Country != "US" + ? StripeConstants.TaxExempt.Reverse + : StripeConstants.TaxExempt.None + }); + + await EnableAutomaticTaxAsync(subscriber, customer); + + var deleteExistingTaxIds = customer.TaxIds?.Any() ?? false + ? customer.TaxIds.Select(taxId => stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id)).ToList() + : []; + + if (billingAddress.TaxId == null) + { + await Task.WhenAll(deleteExistingTaxIds); + return BillingAddress.From(customer.Address); + } + + var updatedTaxId = await stripeAdapter.TaxIdCreateAsync(customer.Id, + new TaxIdCreateOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value }); + + if (billingAddress.TaxId.Code == StripeConstants.TaxIdType.SpanishNIF) + { + updatedTaxId = await stripeAdapter.TaxIdCreateAsync(customer.Id, + new TaxIdCreateOptions + { + Type = StripeConstants.TaxIdType.EUVAT, + Value = $"ES{billingAddress.TaxId.Value}" + }); + } + + await Task.WhenAll(deleteExistingTaxIds); + + return BillingAddress.From(customer.Address, updatedTaxId); + } + + private async Task EnableAutomaticTaxAsync( + ISubscriber subscriber, + Customer customer) + { + if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId)) + { + var subscription = customer.Subscriptions.FirstOrDefault(subscription => + subscription.Id == subscriber.GatewaySubscriptionId); + + if (subscription is { AutomaticTax.Enabled: false }) + { + await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } + } + } +} diff --git a/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs new file mode 100644 index 0000000000..81206b8032 --- /dev/null +++ b/src/Core/Billing/Payment/Commands/UpdatePaymentMethodCommand.cs @@ -0,0 +1,210 @@ +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Braintree; +using Microsoft.Extensions.Logging; +using Stripe; +using Customer = Stripe.Customer; + +namespace Bit.Core.Billing.Payment.Commands; + +public interface IUpdatePaymentMethodCommand +{ + Task> Run( + ISubscriber subscriber, + TokenizedPaymentMethod paymentMethod, + BillingAddress? billingAddress); +} + +public class UpdatePaymentMethodCommand( + IBraintreeGateway braintreeGateway, + IGlobalSettings globalSettings, + ILogger logger, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService) : BaseBillingCommand(logger), IUpdatePaymentMethodCommand +{ + private readonly ILogger _logger = logger; + protected override Conflict DefaultConflict + => new("We had a problem updating your payment method. Please contact support for assistance."); + + public Task> Run( + ISubscriber subscriber, + TokenizedPaymentMethod paymentMethod, + BillingAddress? billingAddress) => HandleAsync(async () => + { + if (string.IsNullOrEmpty(subscriber.GatewayCustomerId)) + { + await subscriberService.CreateStripeCustomer(subscriber); + } + + var customer = await subscriberService.GetCustomer(subscriber); + + var result = paymentMethod.Type switch + { + TokenizablePaymentMethodType.BankAccount => await AddBankAccountAsync(subscriber, customer, paymentMethod.Token), + TokenizablePaymentMethodType.Card => await AddCardAsync(customer, paymentMethod.Token), + TokenizablePaymentMethodType.PayPal => await AddPayPalAsync(subscriber, customer, paymentMethod.Token), + _ => new BadRequest($"Payment method type '{paymentMethod.Type}' is not supported.") + }; + + if (billingAddress != null && customer.Address is not { Country: not null, PostalCode: not null }) + { + await stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + } + }); + } + + return result; + }); + + private async Task> AddBankAccountAsync( + ISubscriber subscriber, + Customer customer, + string token) + { + var setupIntents = await stripeAdapter.SetupIntentList(new SetupIntentListOptions + { + Expand = ["data.payment_method"], + PaymentMethod = token + }); + + switch (setupIntents.Count) + { + case 0: + _logger.LogError("{Command}: Could not find setup intent for subscriber's ({SubscriberID}) bank account", CommandName, subscriber.Id); + return DefaultConflict; + case > 1: + _logger.LogError("{Command}: Found more than one set up intent for subscriber's ({SubscriberID}) bank account", CommandName, subscriber.Id); + return DefaultConflict; + } + + var setupIntent = setupIntents.First(); + + await setupIntentCache.Set(subscriber.Id, setupIntent.Id); + + await UnlinkBraintreeCustomerAsync(customer); + + return MaskedPaymentMethod.From(setupIntent); + } + + private async Task> AddCardAsync( + Customer customer, + string token) + { + var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(token, new PaymentMethodAttachOptions { Customer = customer.Id }); + + await stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions { DefaultPaymentMethod = token } + }); + + await UnlinkBraintreeCustomerAsync(customer); + + return MaskedPaymentMethod.From(paymentMethod.Card); + } + + private async Task> AddPayPalAsync( + ISubscriber subscriber, + Customer customer, + string token) + { + Braintree.Customer braintreeCustomer; + + if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) + { + braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId); + + await ReplaceBraintreePaymentMethodAsync(braintreeCustomer, token); + } + else + { + braintreeCustomer = await CreateBraintreeCustomerAsync(subscriber, token); + + var metadata = new Dictionary + { + [StripeConstants.MetadataKeys.BraintreeCustomerId] = braintreeCustomer.Id + }; + + await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); + } + + var payPalAccount = braintreeCustomer.DefaultPaymentMethod as PayPalAccount; + + return MaskedPaymentMethod.From(payPalAccount!); + } + + private async Task CreateBraintreeCustomerAsync( + ISubscriber subscriber, + string token) + { + var braintreeCustomerId = + subscriber.BraintreeCustomerIdPrefix() + + subscriber.Id.ToString("N").ToLower() + + CoreHelpers.RandomString(3, upper: false, numeric: false); + + var result = await braintreeGateway.Customer.CreateAsync(new CustomerRequest + { + Id = braintreeCustomerId, + CustomFields = new Dictionary + { + [subscriber.BraintreeIdField()] = subscriber.Id.ToString(), + [subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion + }, + Email = subscriber.BillingEmailAddress(), + PaymentMethodNonce = token + }); + + return result.Target; + } + + private async Task ReplaceBraintreePaymentMethodAsync( + Braintree.Customer customer, + string token) + { + var existing = customer.DefaultPaymentMethod; + + var result = await braintreeGateway.PaymentMethod.CreateAsync(new PaymentMethodRequest + { + CustomerId = customer.Id, + PaymentMethodNonce = token + }); + + await braintreeGateway.Customer.UpdateAsync( + customer.Id, + new CustomerRequest { DefaultPaymentMethodToken = result.Target.Token }); + + if (existing != null) + { + await braintreeGateway.PaymentMethod.DeleteAsync(existing.Token); + } + } + + private async Task UnlinkBraintreeCustomerAsync( + Customer customer) + { + if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) + { + var metadata = new Dictionary + { + [StripeConstants.MetadataKeys.RetiredBraintreeCustomerId] = braintreeCustomerId, + [StripeConstants.MetadataKeys.BraintreeCustomerId] = string.Empty + }; + + await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = metadata }); + } + } +} diff --git a/src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs b/src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs new file mode 100644 index 0000000000..4f3e38707c --- /dev/null +++ b/src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs @@ -0,0 +1,62 @@ +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Entities; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Payment.Commands; + +public interface IVerifyBankAccountCommand +{ + Task> Run( + ISubscriber subscriber, + string descriptorCode); +} + +public class VerifyBankAccountCommand( + ILogger logger, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IVerifyBankAccountCommand +{ + private readonly ILogger _logger = logger; + + protected override Conflict DefaultConflict + => new("We had a problem verifying your bank account. Please contact support for assistance."); + + public Task> Run( + ISubscriber subscriber, + string descriptorCode) => HandleAsync(async () => + { + var setupIntentId = await setupIntentCache.Get(subscriber.Id); + + if (string.IsNullOrEmpty(setupIntentId)) + { + _logger.LogError( + "{Command}: Could not find setup intent to verify subscriber's ({SubscriberID}) bank account", + CommandName, subscriber.Id); + return DefaultConflict; + } + + await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId, + new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode }); + + var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, + new SetupIntentGetOptions { Expand = ["payment_method"] }); + + var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId, + new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); + + await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, + new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = setupIntent.PaymentMethodId + } + }); + + return MaskedPaymentMethod.From(paymentMethod.UsBankAccount); + }); +} diff --git a/src/Core/Billing/Payment/Models/BillingAddress.cs b/src/Core/Billing/Payment/Models/BillingAddress.cs new file mode 100644 index 0000000000..39dd1f4121 --- /dev/null +++ b/src/Core/Billing/Payment/Models/BillingAddress.cs @@ -0,0 +1,29 @@ +using Stripe; + +namespace Bit.Core.Billing.Payment.Models; + +public record TaxID(string Code, string Value); + +public record BillingAddress +{ + public required string Country { get; set; } + public required string PostalCode { get; set; } + public string? Line1 { get; set; } + public string? Line2 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public TaxID? TaxId { get; set; } + + public static BillingAddress From(Address address) => new() + { + Country = address.Country, + PostalCode = address.PostalCode, + Line1 = address.Line1, + Line2 = address.Line2, + City = address.City, + State = address.State + }; + + public static BillingAddress From(Address address, TaxId? taxId) => + From(address) with { TaxId = taxId != null ? new TaxID(taxId.Type, taxId.Value) : null }; +} diff --git a/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs b/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs new file mode 100644 index 0000000000..d23ca75025 --- /dev/null +++ b/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Braintree; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Payment.Models; + +public record MaskedBankAccount +{ + public required string BankName { get; init; } + public required string Last4 { get; init; } + public required bool Verified { get; init; } + public string Type => "bankAccount"; +} + +public record MaskedCard +{ + public required string Brand { get; init; } + public required string Last4 { get; init; } + public required string Expiration { get; init; } + public string Type => "card"; +} + +public record MaskedPayPalAccount +{ + public required string Email { get; init; } + public string Type => "payPal"; +} + +[JsonConverter(typeof(MaskedPaymentMethodJsonConverter))] +public class MaskedPaymentMethod(OneOf input) + : OneOfBase(input) +{ + public static implicit operator MaskedPaymentMethod(MaskedBankAccount bankAccount) => new(bankAccount); + public static implicit operator MaskedPaymentMethod(MaskedCard card) => new(card); + public static implicit operator MaskedPaymentMethod(MaskedPayPalAccount payPal) => new(payPal); + + public static MaskedPaymentMethod From(BankAccount bankAccount) => new MaskedBankAccount + { + BankName = bankAccount.BankName, + Last4 = bankAccount.Last4, + Verified = bankAccount.Status == "verified" + }; + + public static MaskedPaymentMethod From(Card card) => new MaskedCard + { + Brand = card.Brand.ToLower(), + Last4 = card.Last4, + Expiration = $"{card.ExpMonth:00}/{card.ExpYear}" + }; + + public static MaskedPaymentMethod From(PaymentMethodCard card) => new MaskedCard + { + Brand = card.Brand.ToLower(), + Last4 = card.Last4, + Expiration = $"{card.ExpMonth:00}/{card.ExpYear}" + }; + + public static MaskedPaymentMethod From(SetupIntent setupIntent) => new MaskedBankAccount + { + BankName = setupIntent.PaymentMethod.UsBankAccount.BankName, + Last4 = setupIntent.PaymentMethod.UsBankAccount.Last4, + Verified = false + }; + + public static MaskedPaymentMethod From(SourceCard sourceCard) => new MaskedCard + { + Brand = sourceCard.Brand.ToLower(), + Last4 = sourceCard.Last4, + Expiration = $"{sourceCard.ExpMonth:00}/{sourceCard.ExpYear}" + }; + + public static MaskedPaymentMethod From(PaymentMethodUsBankAccount bankAccount) => new MaskedBankAccount + { + BankName = bankAccount.BankName, + Last4 = bankAccount.Last4, + Verified = true + }; + + public static MaskedPaymentMethod From(PayPalAccount payPalAccount) => new MaskedPayPalAccount { Email = payPalAccount.Email }; +} + +public class MaskedPaymentMethodJsonConverter : JsonConverter +{ + private const string _typePropertyName = nameof(MaskedBankAccount.Type); + + public override MaskedPaymentMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var element = JsonElement.ParseValue(ref reader); + + if (!element.TryGetProperty(options.PropertyNamingPolicy?.ConvertName(_typePropertyName) ?? _typePropertyName, out var typeProperty)) + { + throw new JsonException( + $"Failed to deserialize {nameof(MaskedPaymentMethod)}: missing '{_typePropertyName}' property"); + } + + var type = typeProperty.GetString(); + + return type switch + { + "bankAccount" => element.Deserialize(options)!, + "card" => element.Deserialize(options)!, + "payPal" => element.Deserialize(options)!, + _ => throw new JsonException($"Failed to deserialize {nameof(MaskedPaymentMethod)}: invalid '{_typePropertyName}' value - '{type}'") + }; + } + + public override void Write(Utf8JsonWriter writer, MaskedPaymentMethod value, JsonSerializerOptions options) + => value.Switch( + bankAccount => JsonSerializer.Serialize(writer, bankAccount, options), + card => JsonSerializer.Serialize(writer, card, options), + payPal => JsonSerializer.Serialize(writer, payPal, options)); +} diff --git a/src/Core/Billing/Payment/Models/ProductUsageType.cs b/src/Core/Billing/Payment/Models/ProductUsageType.cs new file mode 100644 index 0000000000..2ecd1233c6 --- /dev/null +++ b/src/Core/Billing/Payment/Models/ProductUsageType.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Payment.Models; + +public enum ProductUsageType +{ + Personal, + Business +} diff --git a/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs b/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs new file mode 100644 index 0000000000..d27a924360 --- /dev/null +++ b/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Billing.Payment.Models; + +public enum TokenizablePaymentMethodType +{ + BankAccount, + Card, + PayPal +} diff --git a/src/Core/Billing/Payment/Models/TokenizedPaymentMethod.cs b/src/Core/Billing/Payment/Models/TokenizedPaymentMethod.cs new file mode 100644 index 0000000000..9af7c9888a --- /dev/null +++ b/src/Core/Billing/Payment/Models/TokenizedPaymentMethod.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Payment.Models; + +public record TokenizedPaymentMethod +{ + public required TokenizablePaymentMethodType Type { get; set; } + public required string Token { get; set; } +} diff --git a/src/Core/Billing/Payment/Queries/GetBillingAddressQuery.cs b/src/Core/Billing/Payment/Queries/GetBillingAddressQuery.cs new file mode 100644 index 0000000000..e49c2cc993 --- /dev/null +++ b/src/Core/Billing/Payment/Queries/GetBillingAddressQuery.cs @@ -0,0 +1,40 @@ +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Stripe; + +namespace Bit.Core.Billing.Payment.Queries; + +public interface IGetBillingAddressQuery +{ + Task Run(ISubscriber subscriber); +} + +public class GetBillingAddressQuery( + ISubscriberService subscriberService) : IGetBillingAddressQuery +{ + public async Task Run(ISubscriber subscriber) + { + var productUsageType = subscriber.GetProductUsageType(); + + var options = productUsageType switch + { + ProductUsageType.Business => new CustomerGetOptions { Expand = ["tax_ids"] }, + _ => new CustomerGetOptions() + }; + + var customer = await subscriberService.GetCustomer(subscriber, options); + + if (customer is not { Address: { Country: not null, PostalCode: not null } }) + { + return null; + } + + var taxId = productUsageType == ProductUsageType.Business ? customer.TaxIds?.FirstOrDefault() : null; + + return taxId != null + ? BillingAddress.From(customer.Address, taxId) + : BillingAddress.From(customer.Address); + } +} diff --git a/src/Core/Billing/Payment/Queries/GetCreditQuery.cs b/src/Core/Billing/Payment/Queries/GetCreditQuery.cs new file mode 100644 index 0000000000..81d560269b --- /dev/null +++ b/src/Core/Billing/Payment/Queries/GetCreditQuery.cs @@ -0,0 +1,25 @@ +using Bit.Core.Billing.Services; +using Bit.Core.Entities; + +namespace Bit.Core.Billing.Payment.Queries; + +public interface IGetCreditQuery +{ + Task Run(ISubscriber subscriber); +} + +public class GetCreditQuery( + ISubscriberService subscriberService) : IGetCreditQuery +{ + public async Task Run(ISubscriber subscriber) + { + var customer = await subscriberService.GetCustomer(subscriber); + + if (customer == null) + { + return null; + } + + return Convert.ToDecimal(customer.Balance) * -1 / 100; + } +} diff --git a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs new file mode 100644 index 0000000000..ce8f031a5d --- /dev/null +++ b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs @@ -0,0 +1,100 @@ +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Services; +using Braintree; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Payment.Queries; + +public interface IGetPaymentMethodQuery +{ + Task Run(ISubscriber subscriber); +} + +public class GetPaymentMethodQuery( + IBraintreeGateway braintreeGateway, + ILogger logger, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService) : IGetPaymentMethodQuery +{ + public async Task Run(ISubscriber subscriber) + { + var customer = await subscriberService.GetCustomer(subscriber, + new CustomerGetOptions { Expand = ["default_source", "invoice_settings.default_payment_method"] }); + + if (customer == null) + { + return null; + } + + if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) + { + var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId); + + if (braintreeCustomer.DefaultPaymentMethod is PayPalAccount payPalAccount) + { + return new MaskedPayPalAccount { Email = payPalAccount.Email }; + } + + logger.LogWarning("Subscriber ({SubscriberID}) has a linked Braintree customer ({BraintreeCustomerId}) with no PayPal account.", subscriber.Id, braintreeCustomerId); + + return null; + } + + var paymentMethod = customer.InvoiceSettings.DefaultPaymentMethod != null + ? customer.InvoiceSettings.DefaultPaymentMethod.Type switch + { + "card" => MaskedPaymentMethod.From(customer.InvoiceSettings.DefaultPaymentMethod.Card), + "us_bank_account" => MaskedPaymentMethod.From(customer.InvoiceSettings.DefaultPaymentMethod.UsBankAccount), + _ => null + } + : null; + + if (paymentMethod != null) + { + return paymentMethod; + } + + if (customer.DefaultSource != null) + { + paymentMethod = customer.DefaultSource switch + { + Card card => MaskedPaymentMethod.From(card), + BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount), + Source { Card: not null } source => MaskedPaymentMethod.From(source.Card), + _ => null + }; + + if (paymentMethod != null) + { + return paymentMethod; + } + } + + var setupIntentId = await setupIntentCache.Get(subscriber.Id); + + if (string.IsNullOrEmpty(setupIntentId)) + { + return null; + } + + var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + { + Expand = ["payment_method"] + }); + + // ReSharper disable once ConvertIfStatementToReturnStatement + if (!setupIntent.IsUnverifiedBankAccount()) + { + return null; + } + + return MaskedPaymentMethod.From(setupIntent); + } +} diff --git a/src/Core/Billing/Payment/Registrations.cs b/src/Core/Billing/Payment/Registrations.cs new file mode 100644 index 0000000000..1cc7914f10 --- /dev/null +++ b/src/Core/Billing/Payment/Registrations.cs @@ -0,0 +1,24 @@ +using Bit.Core.Billing.Payment.Clients; +using Bit.Core.Billing.Payment.Commands; +using Bit.Core.Billing.Payment.Queries; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Billing.Payment; + +public static class Registrations +{ + public static void AddPaymentOperations(this IServiceCollection services) + { + // Commands + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Queries + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + } +} diff --git a/src/Core/Billing/Pricing/JSON/FreeOrScalableDTOJsonConverter.cs b/src/Core/Billing/Pricing/JSON/FreeOrScalableDTOJsonConverter.cs deleted file mode 100644 index 37a8a4234d..0000000000 --- a/src/Core/Billing/Pricing/JSON/FreeOrScalableDTOJsonConverter.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Text.Json; -using Bit.Core.Billing.Pricing.Models; - -namespace Bit.Core.Billing.Pricing.JSON; - -#nullable enable - -public class FreeOrScalableDTOJsonConverter : TypeReadingJsonConverter -{ - public override FreeOrScalableDTO? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var type = ReadType(reader); - - return type switch - { - "free" => JsonSerializer.Deserialize(ref reader, options) switch - { - null => null, - var free => new FreeOrScalableDTO(free) - }, - "scalable" => JsonSerializer.Deserialize(ref reader, options) switch - { - null => null, - var scalable => new FreeOrScalableDTO(scalable) - }, - _ => null - }; - } - - public override void Write(Utf8JsonWriter writer, FreeOrScalableDTO value, JsonSerializerOptions options) - => value.Switch( - free => JsonSerializer.Serialize(writer, free, options), - scalable => JsonSerializer.Serialize(writer, scalable, options) - ); -} diff --git a/src/Core/Billing/Pricing/JSON/PurchasableDTOJsonConverter.cs b/src/Core/Billing/Pricing/JSON/PurchasableDTOJsonConverter.cs deleted file mode 100644 index f7ae9dc472..0000000000 --- a/src/Core/Billing/Pricing/JSON/PurchasableDTOJsonConverter.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json; -using Bit.Core.Billing.Pricing.Models; - -namespace Bit.Core.Billing.Pricing.JSON; - -#nullable enable -internal class PurchasableDTOJsonConverter : TypeReadingJsonConverter -{ - public override PurchasableDTO? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var type = ReadType(reader); - - return type switch - { - "free" => JsonSerializer.Deserialize(ref reader, options) switch - { - null => null, - var free => new PurchasableDTO(free) - }, - "packaged" => JsonSerializer.Deserialize(ref reader, options) switch - { - null => null, - var packaged => new PurchasableDTO(packaged) - }, - "scalable" => JsonSerializer.Deserialize(ref reader, options) switch - { - null => null, - var scalable => new PurchasableDTO(scalable) - }, - _ => null - }; - } - - public override void Write(Utf8JsonWriter writer, PurchasableDTO value, JsonSerializerOptions options) - => value.Switch( - free => JsonSerializer.Serialize(writer, free, options), - packaged => JsonSerializer.Serialize(writer, packaged, options), - scalable => JsonSerializer.Serialize(writer, scalable, options) - ); -} diff --git a/src/Core/Billing/Pricing/JSON/TypeReadingJsonConverter.cs b/src/Core/Billing/Pricing/JSON/TypeReadingJsonConverter.cs deleted file mode 100644 index ef8d33304e..0000000000 --- a/src/Core/Billing/Pricing/JSON/TypeReadingJsonConverter.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Bit.Core.Billing.Pricing.Models; - -namespace Bit.Core.Billing.Pricing.JSON; - -#nullable enable - -public abstract class TypeReadingJsonConverter : JsonConverter -{ - protected virtual string TypePropertyName => nameof(ScalableDTO.Type).ToLower(); - - protected string? ReadType(Utf8JsonReader reader) - { - while (reader.Read()) - { - if (reader.TokenType != JsonTokenType.PropertyName || reader.GetString()?.ToLower() != TypePropertyName) - { - continue; - } - - reader.Read(); - return reader.GetString(); - } - - return null; - } -} diff --git a/src/Core/Billing/Pricing/Models/Feature.cs b/src/Core/Billing/Pricing/Models/Feature.cs new file mode 100644 index 0000000000..ea9da5217d --- /dev/null +++ b/src/Core/Billing/Pricing/Models/Feature.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Pricing.Models; + +public class Feature +{ + public required string Name { get; set; } + public required string LookupKey { get; set; } +} diff --git a/src/Core/Billing/Pricing/Models/FeatureDTO.cs b/src/Core/Billing/Pricing/Models/FeatureDTO.cs deleted file mode 100644 index a96ac019e3..0000000000 --- a/src/Core/Billing/Pricing/Models/FeatureDTO.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Bit.Core.Billing.Pricing.Models; - -#nullable enable - -public class FeatureDTO -{ - public string Name { get; set; } = null!; - public string LookupKey { get; set; } = null!; -} diff --git a/src/Core/Billing/Pricing/Models/Plan.cs b/src/Core/Billing/Pricing/Models/Plan.cs new file mode 100644 index 0000000000..5b4296474b --- /dev/null +++ b/src/Core/Billing/Pricing/Models/Plan.cs @@ -0,0 +1,25 @@ +namespace Bit.Core.Billing.Pricing.Models; + +public class Plan +{ + public required string LookupKey { get; set; } + public required string Name { get; set; } + public required string Tier { get; set; } + public string? Cadence { get; set; } + public int? LegacyYear { get; set; } + public bool Available { get; set; } + public required Feature[] Features { get; set; } + public required Purchasable Seats { get; set; } + public Scalable? ManagedSeats { get; set; } + public Scalable? Storage { get; set; } + public SecretsManagerPurchasables? SecretsManager { get; set; } + public int? TrialPeriodDays { get; set; } + public required string[] CanUpgradeTo { get; set; } + public required Dictionary AdditionalData { get; set; } +} + +public class SecretsManagerPurchasables +{ + public required FreeOrScalable Seats { get; set; } + public required FreeOrScalable ServiceAccounts { get; set; } +} diff --git a/src/Core/Billing/Pricing/Models/PlanDTO.cs b/src/Core/Billing/Pricing/Models/PlanDTO.cs deleted file mode 100644 index 4ae82b3efe..0000000000 --- a/src/Core/Billing/Pricing/Models/PlanDTO.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Bit.Core.Billing.Pricing.Models; - -#nullable enable - -public class PlanDTO -{ - public string LookupKey { get; set; } = null!; - public string Name { get; set; } = null!; - public string Tier { get; set; } = null!; - public string? Cadence { get; set; } - public int? LegacyYear { get; set; } - public bool Available { get; set; } - public FeatureDTO[] Features { get; set; } = null!; - public PurchasableDTO Seats { get; set; } = null!; - public ScalableDTO? ManagedSeats { get; set; } - public ScalableDTO? Storage { get; set; } - public SecretsManagerPurchasablesDTO? SecretsManager { get; set; } - public int? TrialPeriodDays { get; set; } - public string[] CanUpgradeTo { get; set; } = null!; - public Dictionary AdditionalData { get; set; } = null!; -} - -public class SecretsManagerPurchasablesDTO -{ - public FreeOrScalableDTO Seats { get; set; } = null!; - public FreeOrScalableDTO ServiceAccounts { get; set; } = null!; -} diff --git a/src/Core/Billing/Pricing/Models/Purchasable.cs b/src/Core/Billing/Pricing/Models/Purchasable.cs new file mode 100644 index 0000000000..7cb4ee00c1 --- /dev/null +++ b/src/Core/Billing/Pricing/Models/Purchasable.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OneOf; + +namespace Bit.Core.Billing.Pricing.Models; + +[JsonConverter(typeof(PurchasableJsonConverter))] +public class Purchasable(OneOf input) : OneOfBase(input) +{ + public static implicit operator Purchasable(Free free) => new(free); + public static implicit operator Purchasable(Packaged packaged) => new(packaged); + public static implicit operator Purchasable(Scalable scalable) => new(scalable); + + public T? FromFree(Func select, Func? fallback = null) => + IsT0 ? select(AsT0) : fallback != null ? fallback(this) : default; + + public T? FromPackaged(Func select, Func? fallback = null) => + IsT1 ? select(AsT1) : fallback != null ? fallback(this) : default; + + public T? FromScalable(Func select, Func? fallback = null) => + IsT2 ? select(AsT2) : fallback != null ? fallback(this) : default; + + public bool IsFree => IsT0; + public bool IsPackaged => IsT1; + public bool IsScalable => IsT2; +} + +internal class PurchasableJsonConverter : JsonConverter +{ + private static readonly string _typePropertyName = nameof(Free.Type).ToLower(); + + public override Purchasable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var element = JsonElement.ParseValue(ref reader); + + if (!element.TryGetProperty(options.PropertyNamingPolicy?.ConvertName(_typePropertyName) ?? _typePropertyName, out var typeProperty)) + { + throw new JsonException( + $"Failed to deserialize {nameof(Purchasable)}: missing '{_typePropertyName}' property"); + } + + var type = typeProperty.GetString(); + + return type switch + { + "free" => element.Deserialize(options)!, + "packaged" => element.Deserialize(options)!, + "scalable" => element.Deserialize(options)!, + _ => throw new JsonException($"Failed to deserialize {nameof(Purchasable)}: invalid '{_typePropertyName}' value - '{type}'"), + }; + } + + public override void Write(Utf8JsonWriter writer, Purchasable value, JsonSerializerOptions options) + => value.Switch( + free => JsonSerializer.Serialize(writer, free, options), + packaged => JsonSerializer.Serialize(writer, packaged, options), + scalable => JsonSerializer.Serialize(writer, scalable, options) + ); +} + +[JsonConverter(typeof(FreeOrScalableJsonConverter))] +public class FreeOrScalable(OneOf input) : OneOfBase(input) +{ + public static implicit operator FreeOrScalable(Free free) => new(free); + public static implicit operator FreeOrScalable(Scalable scalable) => new(scalable); + + public T? FromFree(Func select, Func? fallback = null) => + IsT0 ? select(AsT0) : fallback != null ? fallback(this) : default; + + public T? FromScalable(Func select, Func? fallback = null) => + IsT1 ? select(AsT1) : fallback != null ? fallback(this) : default; + + public bool IsFree => IsT0; + public bool IsScalable => IsT1; +} + +public class FreeOrScalableJsonConverter : JsonConverter +{ + private static readonly string _typePropertyName = nameof(Free.Type).ToLower(); + + public override FreeOrScalable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var element = JsonElement.ParseValue(ref reader); + + if (!element.TryGetProperty(options.PropertyNamingPolicy?.ConvertName(_typePropertyName) ?? _typePropertyName, out var typeProperty)) + { + throw new JsonException( + $"Failed to deserialize {nameof(FreeOrScalable)}: missing '{_typePropertyName}' property"); + } + + var type = typeProperty.GetString(); + + return type switch + { + "free" => element.Deserialize(options)!, + "scalable" => element.Deserialize(options)!, + _ => throw new JsonException($"Failed to deserialize {nameof(FreeOrScalable)}: invalid '{_typePropertyName}' value - '{type}'"), + }; + } + + public override void Write(Utf8JsonWriter writer, FreeOrScalable value, JsonSerializerOptions options) + => value.Switch( + free => JsonSerializer.Serialize(writer, free, options), + scalable => JsonSerializer.Serialize(writer, scalable, options) + ); +} + +public class Free +{ + public int Quantity { get; set; } + public string Type => "free"; +} + +public class Packaged +{ + public int Quantity { get; set; } + public string StripePriceId { get; set; } = null!; + public decimal Price { get; set; } + public AdditionalSeats? Additional { get; set; } + public string Type => "packaged"; + + public class AdditionalSeats + { + public string StripePriceId { get; set; } = null!; + public decimal Price { get; set; } + } +} + +public class Scalable +{ + public int Provided { get; set; } + public string StripePriceId { get; set; } = null!; + public decimal Price { get; set; } + public string Type => "scalable"; +} diff --git a/src/Core/Billing/Pricing/Models/PurchasableDTO.cs b/src/Core/Billing/Pricing/Models/PurchasableDTO.cs deleted file mode 100644 index 8ba1c7b731..0000000000 --- a/src/Core/Billing/Pricing/Models/PurchasableDTO.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Text.Json.Serialization; -using Bit.Core.Billing.Pricing.JSON; -using OneOf; - -namespace Bit.Core.Billing.Pricing.Models; - -#nullable enable - -[JsonConverter(typeof(PurchasableDTOJsonConverter))] -public class PurchasableDTO(OneOf input) : OneOfBase(input) -{ - public static implicit operator PurchasableDTO(FreeDTO free) => new(free); - public static implicit operator PurchasableDTO(PackagedDTO packaged) => new(packaged); - public static implicit operator PurchasableDTO(ScalableDTO scalable) => new(scalable); - - public T? FromFree(Func select, Func? fallback = null) => - IsT0 ? select(AsT0) : fallback != null ? fallback(this) : default; - - public T? FromPackaged(Func select, Func? fallback = null) => - IsT1 ? select(AsT1) : fallback != null ? fallback(this) : default; - - public T? FromScalable(Func select, Func? fallback = null) => - IsT2 ? select(AsT2) : fallback != null ? fallback(this) : default; - - public bool IsFree => IsT0; - public bool IsPackaged => IsT1; - public bool IsScalable => IsT2; -} - -[JsonConverter(typeof(FreeOrScalableDTOJsonConverter))] -public class FreeOrScalableDTO(OneOf input) : OneOfBase(input) -{ - public static implicit operator FreeOrScalableDTO(FreeDTO freeDTO) => new(freeDTO); - public static implicit operator FreeOrScalableDTO(ScalableDTO scalableDTO) => new(scalableDTO); - - public T? FromFree(Func select, Func? fallback = null) => - IsT0 ? select(AsT0) : fallback != null ? fallback(this) : default; - - public T? FromScalable(Func select, Func? fallback = null) => - IsT1 ? select(AsT1) : fallback != null ? fallback(this) : default; - - public bool IsFree => IsT0; - public bool IsScalable => IsT1; -} - -public class FreeDTO -{ - public int Quantity { get; set; } - public string Type => "free"; -} - -public class PackagedDTO -{ - public int Quantity { get; set; } - public string StripePriceId { get; set; } = null!; - public decimal Price { get; set; } - public AdditionalSeats? Additional { get; set; } - public string Type => "packaged"; - - public class AdditionalSeats - { - public string StripePriceId { get; set; } = null!; - public decimal Price { get; set; } - } -} - -public class ScalableDTO -{ - public int Provided { get; set; } - public string StripePriceId { get; set; } = null!; - public decimal Price { get; set; } - public string Type => "scalable"; -} diff --git a/src/Core/Billing/Pricing/PlanAdapter.cs b/src/Core/Billing/Pricing/PlanAdapter.cs index f719fd1e87..560987b891 100644 --- a/src/Core/Billing/Pricing/PlanAdapter.cs +++ b/src/Core/Billing/Pricing/PlanAdapter.cs @@ -1,14 +1,12 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing.Models; -using Bit.Core.Models.StaticStore; - -#nullable enable +using Plan = Bit.Core.Billing.Pricing.Models.Plan; namespace Bit.Core.Billing.Pricing; -public record PlanAdapter : Plan +public record PlanAdapter : Core.Models.StaticStore.Plan { - public PlanAdapter(PlanDTO plan) + public PlanAdapter(Plan plan) { Type = ToPlanType(plan.LookupKey); ProductTier = ToProductTierType(Type); @@ -31,6 +29,7 @@ public record PlanAdapter : Plan HasScim = HasFeature("scim"); HasResetPassword = HasFeature("resetPassword"); UsersGetPremium = HasFeature("usersGetPremium"); + HasCustomPermissions = HasFeature("customPermissions"); UpgradeSortOrder = plan.AdditionalData.TryGetValue("upgradeSortOrder", out var upgradeSortOrder) ? int.Parse(upgradeSortOrder) : 0; @@ -87,7 +86,7 @@ public record PlanAdapter : Plan _ => throw new BillingException() // TODO: Flesh out }; - private static PasswordManagerPlanFeatures ToPasswordManagerPlanFeatures(PlanDTO plan) + private static PasswordManagerPlanFeatures ToPasswordManagerPlanFeatures(Plan plan) { var stripePlanId = GetStripePlanId(plan.Seats); var stripeSeatPlanId = GetStripeSeatPlanId(plan.Seats); @@ -127,7 +126,7 @@ public record PlanAdapter : Plan }; } - private static SecretsManagerPlanFeatures ToSecretsManagerPlanFeatures(PlanDTO plan) + private static SecretsManagerPlanFeatures ToSecretsManagerPlanFeatures(Plan plan) { var seats = plan.SecretsManager!.Seats; var serviceAccounts = plan.SecretsManager.ServiceAccounts; @@ -141,6 +140,7 @@ public record PlanAdapter : Plan var stripeSeatPlanId = GetStripeSeatPlanId(seats); var hasAdditionalSeatsOption = seats.IsScalable; var seatPrice = GetSeatPrice(seats); + var baseSeats = GetBaseSeats(seats); var maxSeats = GetMaxSeats(seats); var allowSeatAutoscale = seats.IsScalable; var maxProjects = plan.AdditionalData.TryGetValue("secretsManager.maxProjects", out var value) ? short.Parse(value) : 0; @@ -156,60 +156,69 @@ public record PlanAdapter : Plan StripeSeatPlanId = stripeSeatPlanId, HasAdditionalSeatsOption = hasAdditionalSeatsOption, SeatPrice = seatPrice, + BaseSeats = baseSeats, MaxSeats = maxSeats, AllowSeatAutoscale = allowSeatAutoscale, MaxProjects = maxProjects }; } - private static decimal? GetAdditionalPricePerServiceAccount(FreeOrScalableDTO freeOrScalable) + private static decimal? GetAdditionalPricePerServiceAccount(FreeOrScalable freeOrScalable) => freeOrScalable.FromScalable(x => x.Price); - private static decimal GetBasePrice(PurchasableDTO purchasable) + private static decimal GetBasePrice(Purchasable purchasable) => purchasable.FromPackaged(x => x.Price); - private static int GetBaseSeats(PurchasableDTO purchasable) - => purchasable.FromPackaged(x => x.Quantity); + private static int GetBaseSeats(FreeOrScalable freeOrScalable) + => freeOrScalable.Match( + free => free.Quantity, + scalable => scalable.Provided); - private static short GetBaseServiceAccount(FreeOrScalableDTO freeOrScalable) + private static int GetBaseSeats(Purchasable purchasable) + => purchasable.Match( + free => free.Quantity, + packaged => packaged.Quantity, + scalable => scalable.Provided); + + private static short GetBaseServiceAccount(FreeOrScalable freeOrScalable) => freeOrScalable.Match( free => (short)free.Quantity, scalable => (short)scalable.Provided); - private static short? GetMaxSeats(PurchasableDTO purchasable) + private static short? GetMaxSeats(Purchasable purchasable) => purchasable.Match( free => (short)free.Quantity, packaged => (short)packaged.Quantity, _ => null); - private static short? GetMaxSeats(FreeOrScalableDTO freeOrScalable) + private static short? GetMaxSeats(FreeOrScalable freeOrScalable) => freeOrScalable.FromFree(x => (short)x.Quantity); - private static short? GetMaxServiceAccounts(FreeOrScalableDTO freeOrScalable) + private static short? GetMaxServiceAccounts(FreeOrScalable freeOrScalable) => freeOrScalable.FromFree(x => (short)x.Quantity); - private static decimal GetSeatPrice(PurchasableDTO purchasable) + private static decimal GetSeatPrice(Purchasable purchasable) => purchasable.Match( _ => 0, packaged => packaged.Additional?.Price ?? 0, scalable => scalable.Price); - private static decimal GetSeatPrice(FreeOrScalableDTO freeOrScalable) + private static decimal GetSeatPrice(FreeOrScalable freeOrScalable) => freeOrScalable.FromScalable(x => x.Price); - private static string? GetStripePlanId(PurchasableDTO purchasable) + private static string? GetStripePlanId(Purchasable purchasable) => purchasable.FromPackaged(x => x.StripePriceId); - private static string? GetStripeSeatPlanId(PurchasableDTO purchasable) + private static string? GetStripeSeatPlanId(Purchasable purchasable) => purchasable.Match( _ => null, packaged => packaged.Additional?.StripePriceId, scalable => scalable.StripePriceId); - private static string? GetStripeSeatPlanId(FreeOrScalableDTO freeOrScalable) + private static string? GetStripeSeatPlanId(FreeOrScalable freeOrScalable) => freeOrScalable.FromScalable(x => x.StripePriceId); - private static string? GetStripeServiceAccountPlanId(FreeOrScalableDTO freeOrScalable) + private static string? GetStripeServiceAccountPlanId(FreeOrScalable freeOrScalable) => freeOrScalable.FromScalable(x => x.StripePriceId); #endregion diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs index 14caa54eb4..a3db8ce07f 100644 --- a/src/Core/Billing/Pricing/PricingClient.cs +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -1,7 +1,6 @@ using System.Net; using System.Net.Http.Json; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing.Models; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Settings; @@ -45,7 +44,7 @@ public class PricingClient( if (response.IsSuccessStatusCode) { - var plan = await response.Content.ReadFromJsonAsync(); + var plan = await response.Content.ReadFromJsonAsync(); if (plan == null) { throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); @@ -93,7 +92,7 @@ public class PricingClient( if (response.IsSuccessStatusCode) { - var plans = await response.Content.ReadFromJsonAsync>(); + var plans = await response.Content.ReadFromJsonAsync>(); if (plans == null) { throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); diff --git a/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs b/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs index ae0c28de86..65fd7726f8 100644 --- a/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs +++ b/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Billing.Providers.Migration.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Billing.Providers.Migration.Models; public enum ClientMigrationProgress { diff --git a/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs index 6f3c3be11d..78a2631999 100644 --- a/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs +++ b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Providers.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Providers.Entities; namespace Bit.Core.Billing.Providers.Migration.Models; diff --git a/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs index f4708d4cbd..ba39feab2d 100644 --- a/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs +++ b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Billing.Providers.Migration.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Billing.Providers.Migration.Models; public enum ProviderMigrationProgress { diff --git a/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs index ea7d118cfa..1f38b0d111 100644 --- a/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs @@ -1,4 +1,7 @@ -using System.Text.Json; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Providers.Migration.Models; diff --git a/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs index 3b874579e5..3de49838af 100644 --- a/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; diff --git a/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs index 3a0b579dcf..3a33f96dab 100644 --- a/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; diff --git a/src/Core/Billing/Providers/Services/IProviderBillingService.cs b/src/Core/Billing/Providers/Services/IProviderBillingService.cs index b634f1a81c..518fa1ba98 100644 --- a/src/Core/Billing/Providers/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Providers/Services/IProviderBillingService.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; diff --git a/src/Core/Services/ILicensingService.cs b/src/Core/Billing/Services/ILicensingService.cs similarity index 87% rename from src/Core/Services/ILicensingService.cs rename to src/Core/Billing/Services/ILicensingService.cs index 2115e43085..b6ada998a7 100644 --- a/src/Core/Services/ILicensingService.cs +++ b/src/Core/Billing/Services/ILicensingService.cs @@ -2,10 +2,12 @@ using System.Security.Claims; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; -namespace Bit.Core.Services; +namespace Bit.Core.Billing.Services; public interface ILicensingService { diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index 6910948436..5f656b2c22 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models; using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Enums; @@ -33,6 +36,9 @@ public interface ISubscriberService ISubscriber subscriber, string paymentMethodNonce); + Task CreateStripeCustomer( + ISubscriber subscriber); + /// /// Retrieves a Stripe using the 's property. /// diff --git a/src/Core/Services/Implementations/LicensingService.cs b/src/Core/Billing/Services/Implementations/LicensingService.cs similarity index 94% rename from src/Core/Services/Implementations/LicensingService.cs rename to src/Core/Billing/Services/Implementations/LicensingService.cs index dd603b4b63..3734f1747a 100644 --- a/src/Core/Services/Implementations/LicensingService.cs +++ b/src/Core/Billing/Services/Implementations/LicensingService.cs @@ -1,4 +1,7 @@ -using System.IdentityModel.Tokens.Jwt; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using System.Text; @@ -6,10 +9,13 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Licenses.Models; using Bit.Core.Billing.Licenses.Services; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using IdentityModel; @@ -18,7 +24,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.Tokens; -namespace Bit.Core.Services; +namespace Bit.Core.Billing.Services; public class LicensingService : ILicensingService { @@ -91,7 +97,7 @@ public class LicensingService : ILicensingService } var enabledOrgs = await _organizationRepository.GetManyByEnabledAsync(); - _logger.LogInformation(Constants.BypassFiltersEventId, null, + _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Validating licenses for {NumberOfOrganizations} organizations.", enabledOrgs.Count); var exceptions = new List(); @@ -140,7 +146,7 @@ public class LicensingService : ILicensingService private async Task DisableOrganizationAsync(Organization org, ILicense license, string reason) { - _logger.LogInformation(Constants.BypassFiltersEventId, null, + _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Organization {0} ({1}) has an invalid license and is being disabled. Reason: {2}", org.Id, org.DisplayName(), reason); org.Enabled = false; @@ -159,7 +165,7 @@ public class LicensingService : ILicensingService } var premiumUsers = await _userRepository.GetManyByPremiumAsync(true); - _logger.LogInformation(Constants.BypassFiltersEventId, null, + _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Validating premium for {0} users.", premiumUsers.Count); foreach (var user in premiumUsers) @@ -182,9 +188,8 @@ public class LicensingService : ILicensingService // Only check once per day var now = DateTime.UtcNow; - if (_userCheckCache.ContainsKey(user.Id)) + if (_userCheckCache.TryGetValue(user.Id, out var lastCheck)) { - var lastCheck = _userCheckCache[user.Id]; if (lastCheck < now && now - lastCheck < TimeSpan.FromDays(1)) { return user.Premium; @@ -199,7 +204,7 @@ public class LicensingService : ILicensingService _userCheckCache.Add(user.Id, now); } - _logger.LogInformation(Constants.BypassFiltersEventId, null, + _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Validating premium license for user {0}({1}).", user.Id, user.Email); return await ProcessUserValidationAsync(user); } @@ -231,7 +236,7 @@ public class LicensingService : ILicensingService private async Task DisablePremiumAsync(User user, ILicense license, string reason) { - _logger.LogInformation(Constants.BypassFiltersEventId, null, + _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "User {0}({1}) has an invalid license and premium is being disabled. Reason: {2}", user.Id, user.Email, reason); diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 7496157aaa..5b1b717c20 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Caches; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 75a1bf76ec..73696846ac 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -1,5 +1,9 @@ -using Bit.Core.AdminConsole.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; @@ -10,6 +14,7 @@ using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -24,14 +29,19 @@ using Subscription = Stripe.Subscription; namespace Bit.Core.Billing.Services.Implementations; +using static StripeConstants; + public class SubscriberService( IBraintreeGateway braintreeGateway, IFeatureService featureService, IGlobalSettings globalSettings, ILogger logger, + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, - ITaxService taxService) : ISubscriberService + ITaxService taxService, + IUserRepository userRepository) : ISubscriberService { public async Task CancelSubscription( ISubscriber subscriber, @@ -143,6 +153,110 @@ public class SubscriberService( throw new BillingException(); } +#nullable enable + public async Task CreateStripeCustomer(ISubscriber subscriber) + { + if (!string.IsNullOrEmpty(subscriber.GatewayCustomerId)) + { + throw new ConflictException("Subscriber already has a linked Stripe Customer"); + } + + var options = subscriber switch + { + Organization organization => new CustomerCreateOptions + { + Description = organization.DisplayBusinessName(), + Email = organization.BillingEmail, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = + [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = organization.SubscriberType(), + Value = Max30Characters(organization.DisplayName()) + } + ] + }, + Metadata = new Dictionary + { + [MetadataKeys.OrganizationId] = organization.Id.ToString(), + [MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion + } + }, + Provider provider => new CustomerCreateOptions + { + Description = provider.DisplayBusinessName(), + Email = provider.BillingEmail, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = + [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = provider.SubscriberType(), + Value = Max30Characters(provider.DisplayName()) + } + ] + }, + Metadata = new Dictionary + { + [MetadataKeys.ProviderId] = provider.Id.ToString(), + [MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion + } + }, + User user => new CustomerCreateOptions + { + Description = user.Name, + Email = user.Email, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = + [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = user.SubscriberType(), + Value = Max30Characters(user.SubscriberName()) + } + ] + }, + Metadata = new Dictionary + { + [MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion, + [MetadataKeys.UserId] = user.Id.ToString() + } + }, + _ => throw new ArgumentOutOfRangeException(nameof(subscriber)) + }; + + var customer = await stripeAdapter.CustomerCreateAsync(options); + + switch (subscriber) + { + case Organization organization: + organization.Gateway = GatewayType.Stripe; + organization.GatewayCustomerId = customer.Id; + await organizationRepository.ReplaceAsync(organization); + break; + case Provider provider: + provider.Gateway = GatewayType.Stripe; + provider.GatewayCustomerId = customer.Id; + await providerRepository.ReplaceAsync(provider); + break; + case User user: + user.Gateway = GatewayType.Stripe; + user.GatewayCustomerId = customer.Id; + await userRepository.ReplaceAsync(user); + break; + } + + return customer; + + string? Max30Characters(string? input) + => input?.Length <= 30 ? input : input?[..30]; + } +#nullable disable + public async Task GetCustomer( ISubscriber subscriber, CustomerGetOptions customerGetOptions = null) @@ -648,6 +762,12 @@ public class SubscriberService( { await stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId }); + + if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) + { + await stripeAdapter.TaxIdCreateAsync(customer.Id, + new TaxIdCreateOptions { Type = StripeConstants.TaxIdType.EUVAT, Value = $"ES{taxInformation.TaxId}" }); + } } catch (StripeException e) { diff --git a/src/Core/Services/NoopImplementations/NoopLicensingService.cs b/src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs similarity index 93% rename from src/Core/Services/NoopImplementations/NoopLicensingService.cs rename to src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs index b181e61138..a54ba3546a 100644 --- a/src/Core/Services/NoopImplementations/NoopLicensingService.cs +++ b/src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs @@ -2,13 +2,15 @@ using System.Security.Claims; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; using Bit.Core.Settings; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; -namespace Bit.Core.Services; +namespace Bit.Core.Billing.Services; public class NoopLicensingService : ILicensingService { diff --git a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs index 304abbaae0..6e061293c7 100644 --- a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs +++ b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs @@ -1,8 +1,7 @@ -#nullable enable +using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Tax.Services; using Bit.Core.Services; @@ -20,102 +19,98 @@ public class PreviewTaxAmountCommand( ILogger logger, IPricingClient pricingClient, IStripeAdapter stripeAdapter, - ITaxService taxService) : IPreviewTaxAmountCommand + ITaxService taxService) : BaseBillingCommand(logger), IPreviewTaxAmountCommand { - public async Task> Run(OrganizationTrialParameters parameters) - { - var (planType, productType, taxInformation) = parameters; + protected override Conflict DefaultConflict + => new("We had a problem calculating your tax obligation. Please contact support for assistance."); - var plan = await pricingClient.GetPlanOrThrow(planType); - - var options = new InvoiceCreatePreviewOptions + public Task> Run(OrganizationTrialParameters parameters) + => HandleAsync(async () => { - Currency = "usd", - CustomerDetails = new InvoiceCustomerDetailsOptions + var (planType, productType, taxInformation) = parameters; + + var plan = await pricingClient.GetPlanOrThrow(planType); + + var options = new InvoiceCreatePreviewOptions { - Address = new AddressOptions + Currency = "usd", + CustomerDetails = new InvoiceCustomerDetailsOptions { - Country = taxInformation.Country, - PostalCode = taxInformation.PostalCode - } - }, - SubscriptionDetails = new InvoiceSubscriptionDetailsOptions - { - Items = [ - new InvoiceSubscriptionDetailsItemOptions + Address = new AddressOptions { - Price = plan.HasNonSeatBasedPasswordManagerPlan() ? plan.PasswordManager.StripePlanId : plan.PasswordManager.StripeSeatPlanId, - Quantity = 1 + Country = taxInformation.Country, + PostalCode = taxInformation.PostalCode } - ] - } - }; - - if (productType == ProductType.SecretsManager) - { - options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions - { - Price = plan.SecretsManager.StripeSeatPlanId, - Quantity = 1 - }); - - options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone; - } - - if (!string.IsNullOrEmpty(taxInformation.TaxId)) - { - var taxIdType = taxService.GetStripeTaxCode( - taxInformation.Country, - taxInformation.TaxId); - - if (string.IsNullOrEmpty(taxIdType)) - { - return BadRequest.UnknownTaxIdType; - } - - options.CustomerDetails.TaxIds = [ - new InvoiceCustomerDetailsTaxIdOptions + }, + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { - Type = taxIdType, - Value = taxInformation.TaxId + Items = + [ + new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.HasNonSeatBasedPasswordManagerPlan() + ? plan.PasswordManager.StripePlanId + : plan.PasswordManager.StripeSeatPlanId, + Quantity = 1 + } + ] } - ]; - } - - if (planType.GetProductTier() == ProductTierType.Families) - { - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - } - else - { - options.AutomaticTax = new InvoiceAutomaticTaxOptions - { - Enabled = options.CustomerDetails.Address.Country == "US" || - options.CustomerDetails.TaxIds is [_, ..] }; - } - try - { + if (productType == ProductType.SecretsManager) + { + options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeSeatPlanId, + Quantity = 1 + }); + + options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone; + } + + if (!string.IsNullOrEmpty(taxInformation.TaxId)) + { + var taxIdType = taxService.GetStripeTaxCode( + taxInformation.Country, + taxInformation.TaxId); + + if (string.IsNullOrEmpty(taxIdType)) + { + return new BadRequest( + "We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance."); + } + + options.CustomerDetails.TaxIds = + [ + new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = taxInformation.TaxId } + ]; + + if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) + { + options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions + { + Type = StripeConstants.TaxIdType.EUVAT, + Value = $"ES{parameters.TaxInformation.TaxId}" + }); + } + } + + if (planType.GetProductTier() == ProductTierType.Families) + { + options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; + } + else + { + options.AutomaticTax = new InvoiceAutomaticTaxOptions + { + Enabled = options.CustomerDetails.Address.Country == "US" || + options.CustomerDetails.TaxIds is [_, ..] + }; + } + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); return Convert.ToDecimal(invoice.Tax) / 100; - } - catch (StripeException stripeException) when (stripeException.StripeError.Code == - StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) - { - return BadRequest.TaxLocationInvalid; - } - catch (StripeException stripeException) when (stripeException.StripeError.Code == - StripeConstants.ErrorCodes.TaxIdInvalid) - { - return BadRequest.TaxIdNumberInvalid; - } - catch (StripeException stripeException) - { - logger.LogError(stripeException, "Stripe responded with an error during {Operation}. Code: {Code}", nameof(PreviewTaxAmountCommand), stripeException.StripeError.Code); - return new Unhandled(); - } - } + }); } #region Command Parameters diff --git a/src/Core/Billing/Tax/Models/TaxIdType.cs b/src/Core/Billing/Tax/Models/TaxIdType.cs index 6f8cfdde99..005b1eb6a6 100644 --- a/src/Core/Billing/Tax/Models/TaxIdType.cs +++ b/src/Core/Billing/Tax/Models/TaxIdType.cs @@ -1,4 +1,7 @@ -using System.Text.RegularExpressions; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.RegularExpressions; namespace Bit.Core.Billing.Tax.Models; diff --git a/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs b/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs index 340f07b56c..db5ba190bd 100644 --- a/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/PreviewIndividualInvoiceRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Billing.Tax.Requests; diff --git a/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs b/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs index bfb47e7b2c..f0bc368f07 100644 --- a/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/PreviewOrganizationInvoiceRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Enums; using Bit.Core.Enums; diff --git a/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs b/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs index 13d4870ac5..cd1046f480 100644 --- a/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs +++ b/src/Core/Billing/Tax/Requests/TaxInformationRequestModel.cs @@ -1,4 +1,7 @@ -using System.ComponentModel.DataAnnotations; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Billing.Tax.Requests; diff --git a/src/Core/Billing/Tax/Services/Implementations/TaxService.cs b/src/Core/Billing/Tax/Services/Implementations/TaxService.cs index 204c997335..55a8ab1c50 100644 --- a/src/Core/Billing/Tax/Services/Implementations/TaxService.cs +++ b/src/Core/Billing/Tax/Services/Implementations/TaxService.cs @@ -1,4 +1,7 @@ -using System.Text.RegularExpressions; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text.RegularExpressions; using Bit.Core.Billing.Tax.Models; namespace Bit.Core.Billing.Tax.Services.Implementations; diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs index ebb7b0e525..2ee6b75664 100644 --- a/src/Core/Billing/Utilities.cs +++ b/src/Core/Billing/Utilities.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Models; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Billing.Models; using Bit.Core.Billing.Tax.Models; using Bit.Core.Services; using Stripe; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 1c31ffaab4..7cedf42b5b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -1,4 +1,7 @@ -using System.Reflection; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Reflection; namespace Bit.Core; @@ -23,6 +26,7 @@ public static class Constants public const string Fido2KeyCipherMinimumVersion = "2023.10.0"; public const string SSHKeyCipherMinimumVersion = "2024.12.0"; + public const string DenyLegacyUserMinimumVersion = "2025.6.0"; /// /// Used by IdentityServer to identify our own provider. @@ -106,9 +110,12 @@ public static class FeatureFlagKeys public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission"; public const string PolicyRequirements = "pm-14439-policy-requirements"; - public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; + public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; + public const string ImportAsyncRefactor = "pm-22583-refactor-import-async"; + public const string CreateDefaultLocation = "pm-19467-create-default-location"; + public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; /* Auth Team */ public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; @@ -119,6 +126,8 @@ public static class FeatureFlagKeys public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public const string RecoveryCodeLogin = "pm-17128-recovery-code-login"; + public const string Otp6Digits = "pm-18612-otp-6-digits"; + public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; @@ -135,6 +144,7 @@ public static class FeatureFlagKeys public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill"; public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; public const string InlineMenuTotp = "inline-menu-totp"; + public const string WindowsDesktopAutotype = "windows-desktop-autotype"; /* Billing Team */ public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; @@ -142,19 +152,15 @@ public static class FeatureFlagKeys public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string UsePricingService = "use-pricing-service"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; - public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; - public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup"; public const string UseOrganizationWarningsService = "use-organization-warnings-service"; public const string PM20322_AllowTrialLength0 = "pm-20322-allow-trial-length-0"; public const string PM21092_SetNonUSBusinessUseToReverseCharge = "pm-21092-set-non-us-business-use-to-reverse-charge"; public const string PM21383_GetProviderPriceFromStripe = "pm-21383-get-provider-price-from-stripe"; - - /* Data Insights and Reporting Team */ - public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; - public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; + public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout"; + public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; @@ -165,6 +171,7 @@ public static class FeatureFlagKeys public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; public const string UserSdkForDecryption = "use-sdk-for-decryption"; public const string PM17987_BlockType0 = "pm-17987-block-type-0"; + public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings"; /* Mobile Team */ public const string NativeCarouselFlow = "native-carousel-flow"; @@ -180,17 +187,22 @@ public static class FeatureFlagKeys public const string EnablePMFlightRecorder = "enable-pm-flight-recorder"; public const string MobileErrorReporting = "mobile-error-reporting"; public const string AndroidChromeAutofill = "android-chrome-autofill"; + public const string UserManagedPrivilegedApps = "pm-18970-user-managed-privileged-apps"; + public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings"; + public const string AppIntents = "app-intents"; + public const string SendAccess = "pm-19394-send-access-control"; /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; public const string StorageReseedRefactor = "storage-reseed-refactor"; public const string WebPush = "web-push"; - public const string RecordInstallationLastActivityDate = "installation-last-activity-date"; public const string IpcChannelFramework = "ipc-channel-framework"; + public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; /* Tools Team */ - public const string ItemShare = "item-share"; public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; + public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators"; + public const string UseChromiumImporter = "pm-23982-chromium-importer"; /* Vault Team */ public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; @@ -200,8 +212,11 @@ public static class FeatureFlagKeys public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; public const string EndUserNotifications = "pm-10609-end-user-notifications"; - public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string PhishingDetection = "phishing-detection"; + public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy"; + public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view"; + public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp"; + public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption"; public static List GetAllKeys() { diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index cbd90055b0..85c8a81523 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -1,4 +1,7 @@ -using System.Security.Claims; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Security.Claims; using Bit.Core.AdminConsole.Context; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; @@ -64,39 +67,39 @@ public class CurrentContext : ICurrentContext HttpContext = httpContext; await BuildAsync(httpContext.User, globalSettings); - if (DeviceIdentifier == null && httpContext.Request.Headers.ContainsKey("Device-Identifier")) + if (DeviceIdentifier == null && httpContext.Request.Headers.TryGetValue("Device-Identifier", out var deviceIdentifier)) { - DeviceIdentifier = httpContext.Request.Headers["Device-Identifier"]; + DeviceIdentifier = deviceIdentifier; } - if (httpContext.Request.Headers.ContainsKey("Device-Type") && - Enum.TryParse(httpContext.Request.Headers["Device-Type"].ToString(), out DeviceType dType)) + if (httpContext.Request.Headers.TryGetValue("Device-Type", out var deviceType) && + Enum.TryParse(deviceType.ToString(), out DeviceType dType)) { DeviceType = dType; } - if (!BotScore.HasValue && httpContext.Request.Headers.ContainsKey("X-Cf-Bot-Score") && - int.TryParse(httpContext.Request.Headers["X-Cf-Bot-Score"], out var parsedBotScore)) + if (!BotScore.HasValue && httpContext.Request.Headers.TryGetValue("X-Cf-Bot-Score", out var cfBotScore) && + int.TryParse(cfBotScore, out var parsedBotScore)) { BotScore = parsedBotScore; } - if (httpContext.Request.Headers.ContainsKey("X-Cf-Worked-Proxied")) + if (httpContext.Request.Headers.TryGetValue("X-Cf-Worked-Proxied", out var cfWorkedProxied)) { - CloudflareWorkerProxied = httpContext.Request.Headers["X-Cf-Worked-Proxied"] == "1"; + CloudflareWorkerProxied = cfWorkedProxied == "1"; } - if (httpContext.Request.Headers.ContainsKey("X-Cf-Is-Bot")) + if (httpContext.Request.Headers.TryGetValue("X-Cf-Is-Bot", out var cfIsBot)) { - IsBot = httpContext.Request.Headers["X-Cf-Is-Bot"] == "1"; + IsBot = cfIsBot == "1"; } - if (httpContext.Request.Headers.ContainsKey("X-Cf-Maybe-Bot")) + if (httpContext.Request.Headers.TryGetValue("X-Cf-Maybe-Bot", out var cfMaybeBot)) { - MaybeBot = httpContext.Request.Headers["X-Cf-Maybe-Bot"] == "1"; + MaybeBot = cfMaybeBot == "1"; } - if (httpContext.Request.Headers.ContainsKey("Bitwarden-Client-Version") && Version.TryParse(httpContext.Request.Headers["Bitwarden-Client-Version"], out var cVersion)) + if (httpContext.Request.Headers.TryGetValue("Bitwarden-Client-Version", out var bitWardenClientVersion) && Version.TryParse(bitWardenClientVersion, out var cVersion)) { ClientVersion = cVersion; } @@ -190,14 +193,14 @@ public class CurrentContext : ICurrentContext private List GetOrganizations(Dictionary> claimsDict, bool orgApi) { - var accessSecretsManager = claimsDict.ContainsKey(Claims.SecretsManagerAccess) - ? claimsDict[Claims.SecretsManagerAccess].ToDictionary(s => s.Value, _ => true) + var accessSecretsManager = claimsDict.TryGetValue(Claims.SecretsManagerAccess, out var secretsManagerAccessClaim) + ? secretsManagerAccessClaim.ToDictionary(s => s.Value, _ => true) : new Dictionary(); var organizations = new List(); - if (claimsDict.ContainsKey(Claims.OrganizationOwner)) + if (claimsDict.TryGetValue(Claims.OrganizationOwner, out var organizationOwnerClaim)) { - organizations.AddRange(claimsDict[Claims.OrganizationOwner].Select(c => + organizations.AddRange(organizationOwnerClaim.Select(c => new CurrentContextOrganization { Id = new Guid(c.Value), @@ -214,9 +217,9 @@ public class CurrentContext : ICurrentContext }); } - if (claimsDict.ContainsKey(Claims.OrganizationAdmin)) + if (claimsDict.TryGetValue(Claims.OrganizationAdmin, out var organizationAdminClaim)) { - organizations.AddRange(claimsDict[Claims.OrganizationAdmin].Select(c => + organizations.AddRange(organizationAdminClaim.Select(c => new CurrentContextOrganization { Id = new Guid(c.Value), @@ -225,9 +228,9 @@ public class CurrentContext : ICurrentContext })); } - if (claimsDict.ContainsKey(Claims.OrganizationUser)) + if (claimsDict.TryGetValue(Claims.OrganizationUser, out var organizationUserClaim)) { - organizations.AddRange(claimsDict[Claims.OrganizationUser].Select(c => + organizations.AddRange(organizationUserClaim.Select(c => new CurrentContextOrganization { Id = new Guid(c.Value), @@ -236,9 +239,9 @@ public class CurrentContext : ICurrentContext })); } - if (claimsDict.ContainsKey(Claims.OrganizationCustom)) + if (claimsDict.TryGetValue(Claims.OrganizationCustom, out var organizationCustomClaim)) { - organizations.AddRange(claimsDict[Claims.OrganizationCustom].Select(c => + organizations.AddRange(organizationCustomClaim.Select(c => new CurrentContextOrganization { Id = new Guid(c.Value), @@ -254,9 +257,9 @@ public class CurrentContext : ICurrentContext private List GetProviders(Dictionary> claimsDict) { var providers = new List(); - if (claimsDict.ContainsKey(Claims.ProviderAdmin)) + if (claimsDict.TryGetValue(Claims.ProviderAdmin, out var providerAdminClaim)) { - providers.AddRange(claimsDict[Claims.ProviderAdmin].Select(c => + providers.AddRange(providerAdminClaim.Select(c => new CurrentContextProvider { Id = new Guid(c.Value), @@ -264,9 +267,9 @@ public class CurrentContext : ICurrentContext })); } - if (claimsDict.ContainsKey(Claims.ProviderServiceUser)) + if (claimsDict.TryGetValue(Claims.ProviderServiceUser, out var providerServiceUserClaim)) { - providers.AddRange(claimsDict[Claims.ProviderServiceUser].Select(c => + providers.AddRange(providerServiceUserClaim.Select(c => new CurrentContextProvider { Id = new Guid(c.Value), @@ -504,20 +507,20 @@ public class CurrentContext : ICurrentContext private string GetClaimValue(Dictionary> claims, string type) { - if (!claims.ContainsKey(type)) + if (!claims.TryGetValue(type, out var claim)) { return null; } - return claims[type].FirstOrDefault()?.Value; + return claim.FirstOrDefault()?.Value; } private Permissions SetOrganizationPermissionsFromClaims(string organizationId, Dictionary> claimsDict) { bool hasClaim(string claimKey) { - return claimsDict.ContainsKey(claimKey) ? - claimsDict[claimKey].Any(x => x.Value == organizationId) : false; + return claimsDict.TryGetValue(claimKey, out var claim) ? + claim.Any(x => x.Value == organizationId) : false; } return new Permissions diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 633c3452d9..79cd8bf9b8 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + @@ -34,9 +34,9 @@ - + - + @@ -59,7 +59,7 @@ - + diff --git a/src/Core/Dirt/Entities/OrganizationApplication.cs b/src/Core/Dirt/Entities/OrganizationApplication.cs new file mode 100644 index 0000000000..48a6ef4257 --- /dev/null +++ b/src/Core/Dirt/Entities/OrganizationApplication.cs @@ -0,0 +1,21 @@ +#nullable enable + +using Bit.Core.Entities; +using Bit.Core.Utilities; + +namespace Bit.Core.Dirt.Entities; + +public class OrganizationApplication : ITableObject, IRevisable +{ + public Guid Id { get; set; } + public Guid OrganizationId { get; set; } + public string Applications { get; set; } = string.Empty; + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public string ContentEncryptionKey { get; set; } = string.Empty; + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } +} diff --git a/src/Core/Dirt/Entities/OrganizationReport.cs b/src/Core/Dirt/Entities/OrganizationReport.cs new file mode 100644 index 0000000000..92975ca441 --- /dev/null +++ b/src/Core/Dirt/Entities/OrganizationReport.cs @@ -0,0 +1,22 @@ +#nullable enable + +using Bit.Core.Entities; +using Bit.Core.Utilities; + +namespace Bit.Core.Dirt.Entities; + +public class OrganizationReport : ITableObject +{ + public Guid Id { get; set; } + public Guid OrganizationId { get; set; } + public DateTime Date { get; set; } + public string ReportData { get; set; } = string.Empty; + public DateTime CreationDate { get; set; } = DateTime.UtcNow; + + public string ContentEncryptionKey { get; set; } = string.Empty; + + public void SetNewId() + { + Id = CoreHelpers.GenerateComb(); + } +} diff --git a/src/Core/Dirt/Reports/Entities/PasswordHealthReportApplication.cs b/src/Core/Dirt/Entities/PasswordHealthReportApplication.cs similarity index 84% rename from src/Core/Dirt/Reports/Entities/PasswordHealthReportApplication.cs rename to src/Core/Dirt/Entities/PasswordHealthReportApplication.cs index 9d89edf633..5a72a0aab0 100644 --- a/src/Core/Dirt/Reports/Entities/PasswordHealthReportApplication.cs +++ b/src/Core/Dirt/Entities/PasswordHealthReportApplication.cs @@ -1,9 +1,9 @@ -using Bit.Core.Entities; +#nullable enable + +using Bit.Core.Entities; using Bit.Core.Utilities; -#nullable enable - -namespace Bit.Core.Tools.Entities; +namespace Bit.Core.Dirt.Entities; public class PasswordHealthReportApplication : ITableObject, IRevisable { diff --git a/src/Core/Dirt/Reports/Models/Data/MemberAccessCipherDetails.cs b/src/Core/Dirt/Models/Data/MemberAccessCipherDetails.cs similarity index 83% rename from src/Core/Dirt/Reports/Models/Data/MemberAccessCipherDetails.cs rename to src/Core/Dirt/Models/Data/MemberAccessCipherDetails.cs index 943d56c53e..326a7c61cb 100644 --- a/src/Core/Dirt/Reports/Models/Data/MemberAccessCipherDetails.cs +++ b/src/Core/Dirt/Models/Data/MemberAccessCipherDetails.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Tools.Models.Data; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Models.Data; public class MemberAccessDetails { @@ -30,13 +33,13 @@ public class MemberAccessCipherDetails public bool UsesKeyConnector { get; set; } /// - /// The details for the member's collection access depending - /// on the collections and groups they are assigned to + /// The details for the member's collection access depending + /// on the collections and groups they are assigned to /// public IEnumerable AccessDetails { get; set; } /// - /// A distinct list of the cipher ids associated with + /// A distinct list of the cipher ids associated with /// the organization member /// public IEnumerable CipherIds { get; set; } diff --git a/src/Core/Dirt/Models/Data/MemberAccessReportDetail.cs b/src/Core/Dirt/Models/Data/MemberAccessReportDetail.cs new file mode 100644 index 0000000000..7b54822a1e --- /dev/null +++ b/src/Core/Dirt/Models/Data/MemberAccessReportDetail.cs @@ -0,0 +1,22 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.Models.Data; + +public class MemberAccessReportDetail +{ + public Guid? UserGuid { get; set; } + public string UserName { get; set; } + public string Email { get; set; } + public bool TwoFactorEnabled { get; set; } + public bool AccountRecoveryEnabled { get; set; } + public bool UsesKeyConnector { get; set; } + public Guid? CollectionId { get; set; } + public Guid? GroupId { get; set; } + public string GroupName { get; set; } + public string CollectionName { get; set; } + public bool? ReadOnly { get; set; } + public bool? HidePasswords { get; set; } + public bool? Manage { get; set; } + public IEnumerable CipherIds { get; set; } +} diff --git a/src/Core/Dirt/Models/Data/OrganizationMemberBaseDetail.cs b/src/Core/Dirt/Models/Data/OrganizationMemberBaseDetail.cs new file mode 100644 index 0000000000..a68e920e66 --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationMemberBaseDetail.cs @@ -0,0 +1,22 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.Models.Data; + +public class OrganizationMemberBaseDetail +{ + public Guid? UserGuid { get; set; } + public string UserName { get; set; } + public string Email { get; set; } + public string TwoFactorProviders { get; set; } + public bool UsesKeyConnector { get; set; } + public string ResetPasswordKey { get; set; } + public Guid? CollectionId { get; set; } + public Guid? GroupId { get; set; } + public string GroupName { get; set; } + public string CollectionName { get; set; } + public bool? ReadOnly { get; set; } + public bool? HidePasswords { get; set; } + public bool? Manage { get; set; } + public Guid CipherId { get; set; } +} diff --git a/src/Core/Dirt/Models/Data/RiskInsightsReportDetail.cs b/src/Core/Dirt/Models/Data/RiskInsightsReportDetail.cs new file mode 100644 index 0000000000..acc468eb11 --- /dev/null +++ b/src/Core/Dirt/Models/Data/RiskInsightsReportDetail.cs @@ -0,0 +1,13 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.Models.Data; + +public class RiskInsightsReportDetail +{ + public Guid? UserGuid { get; set; } + public string UserName { get; set; } + public string Email { get; set; } + public bool UsesKeyConnector { get; set; } + public IEnumerable CipherIds { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs new file mode 100644 index 0000000000..66d25cdf56 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs @@ -0,0 +1,74 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class AddOrganizationReportCommand : IAddOrganizationReportCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private ILogger _logger; + + public AddOrganizationReportCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task AddOrganizationReportAsync(AddOrganizationReportRequest request) + { + _logger.LogInformation("Adding organization report for organization {organizationId}", request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogInformation("Failed to add organization {organizationId} report: {errorMessage}", request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var organizationReport = new OrganizationReport + { + OrganizationId = request.OrganizationId, + ReportData = request.ReportData, + Date = request.Date == default ? DateTime.UtcNow : request.Date, + CreationDate = DateTime.UtcNow, + }; + + organizationReport.SetNewId(); + + var data = await _organizationReportRepo.CreateAsync(organizationReport); + + _logger.LogInformation("Successfully added organization report for organization {organizationId}, {organizationReportId}", + request.OrganizationId, data.Id); + + return data; + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync( + AddOrganizationReportRequest request) + { + // verify that the organization exists + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + // ensure that we have report data + if (string.IsNullOrWhiteSpace(request.ReportData)) + { + return (false, "Report Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs index b191799ba0..159cbb5c77 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/AddPasswordHealthReportApplicationCommand.cs @@ -1,11 +1,11 @@ -using Bit.Core.Exceptions; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.ReportFeatures.Interfaces; -using Bit.Core.Tools.ReportFeatures.Requests; -using Bit.Core.Tools.Repositories; -namespace Bit.Core.Tools.ReportFeatures; +namespace Bit.Core.Dirt.Reports.ReportFeatures; public class AddPasswordHealthReportApplicationCommand : IAddPasswordHealthReportApplicationCommand { diff --git a/src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs new file mode 100644 index 0000000000..8fe206c1f1 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs @@ -0,0 +1,45 @@ +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class DropOrganizationReportCommand : IDropOrganizationReportCommand +{ + private IOrganizationReportRepository _organizationReportRepo; + private ILogger _logger; + + public DropOrganizationReportCommand( + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task DropOrganizationReportAsync(DropOrganizationReportRequest request) + { + _logger.LogInformation("Dropping organization report for organization {organizationId}", + request.OrganizationId); + + var data = await _organizationReportRepo.GetByOrganizationIdAsync(request.OrganizationId); + if (data == null || data.Count() == 0) + { + _logger.LogInformation("No organization reports found for organization {organizationId}", request.OrganizationId); + throw new BadRequestException("No data found."); + } + + data + .Where(_ => request.OrganizationReportIds.Contains(_.Id)) + .ToList() + .ForEach(async reportId => + { + _logger.LogInformation("Dropping organization report {organizationReportId} for organization {organizationId}", + reportId, request.OrganizationId); + + await _organizationReportRepo.DeleteAsync(reportId); + }); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs index 73a8f84e6a..b955c2c958 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/DropPasswordHealthReportApplicationCommand.cs @@ -1,9 +1,9 @@ -using Bit.Core.Exceptions; -using Bit.Core.Tools.ReportFeatures.Interfaces; -using Bit.Core.Tools.ReportFeatures.Requests; -using Bit.Core.Tools.Repositories; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; -namespace Bit.Core.Tools.ReportFeatures; +namespace Bit.Core.Dirt.Reports.ReportFeatures; public class DropPasswordHealthReportApplicationCommand : IDropPasswordHealthReportApplicationCommand { diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs new file mode 100644 index 0000000000..e536fdfddc --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs @@ -0,0 +1,43 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportQuery : IGetOrganizationReportQuery +{ + private IOrganizationReportRepository _organizationReportRepo; + private ILogger _logger; + + public GetOrganizationReportQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task> GetOrganizationReportAsync(Guid organizationId) + { + if (organizationId == Guid.Empty) + { + throw new BadRequestException("OrganizationId is required."); + } + + _logger.LogInformation("Fetching organization reports for organization {organizationId}", organizationId); + return await _organizationReportRepo.GetByOrganizationIdAsync(organizationId); + } + + public async Task GetLatestOrganizationReportAsync(Guid organizationId) + { + if (organizationId == Guid.Empty) + { + throw new BadRequestException("OrganizationId is required."); + } + + _logger.LogInformation("Fetching latest organization report for organization {organizationId}", organizationId); + return await _organizationReportRepo.GetLatestByOrganizationIdAsync(organizationId); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs index 5baf5b2f72..40d86338db 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetPasswordHealthReportApplicationQuery.cs @@ -1,9 +1,9 @@ -using Bit.Core.Exceptions; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.ReportFeatures.Interfaces; -using Bit.Core.Tools.Repositories; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; -namespace Bit.Core.Tools.ReportFeatures; +namespace Bit.Core.Dirt.Reports.ReportFeatures; public class GetPasswordHealthReportApplicationQuery : IGetPasswordHealthReportApplicationQuery { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddOrganizationReportCommand.cs new file mode 100644 index 0000000000..3677b9794b --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddOrganizationReportCommand.cs @@ -0,0 +1,10 @@ + +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IAddOrganizationReportCommand +{ + Task AddOrganizationReportAsync(AddOrganizationReportRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs index 9d145a79b6..0fd21751de 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IAddPasswordHealthReportApplicationCommand.cs @@ -1,7 +1,7 @@ -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.ReportFeatures.Requests; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -namespace Bit.Core.Tools.ReportFeatures.Interfaces; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; public interface IAddPasswordHealthReportApplicationCommand { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs new file mode 100644 index 0000000000..1ed9059f56 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs @@ -0,0 +1,9 @@ + +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IDropOrganizationReportCommand +{ + Task DropOrganizationReportAsync(DropOrganizationReportRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs index 0adf09cab8..8e97e32ac7 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropPasswordHealthReportApplicationCommand.cs @@ -1,6 +1,6 @@ -using Bit.Core.Tools.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -namespace Bit.Core.Tools.ReportFeatures.Interfaces; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; public interface IDropPasswordHealthReportApplicationCommand { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs new file mode 100644 index 0000000000..f596e8f517 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportQuery +{ + Task> GetOrganizationReportAsync(Guid organizationId); + Task GetLatestOrganizationReportAsync(Guid organizationId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs index f24119c2b7..f7fe80b098 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetPasswordHealthReportApplicationQuery.cs @@ -1,6 +1,6 @@ -using Bit.Core.Tools.Entities; +using Bit.Core.Dirt.Entities; -namespace Bit.Core.Tools.ReportFeatures.Interfaces; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; public interface IGetPasswordHealthReportApplicationQuery { diff --git a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs deleted file mode 100644 index 0c165a7dc2..0000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessCipherDetailsQuery.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System.Collections.Concurrent; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; -using Bit.Core.Entities; -using Bit.Core.Models.Data; -using Bit.Core.Models.Data.Organizations; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces; -using Bit.Core.Tools.ReportFeatures.Requests; -using Bit.Core.Vault.Models.Data; -using Bit.Core.Vault.Queries; -using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; - -namespace Bit.Core.Tools.ReportFeatures; - -public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery -{ - private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; - private readonly IGroupRepository _groupRepository; - private readonly ICollectionRepository _collectionRepository; - private readonly IOrganizationCiphersQuery _organizationCiphersQuery; - private readonly IApplicationCacheService _applicationCacheService; - private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; - - public MemberAccessCipherDetailsQuery( - IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, - IGroupRepository groupRepository, - ICollectionRepository collectionRepository, - IOrganizationCiphersQuery organizationCiphersQuery, - IApplicationCacheService applicationCacheService, - ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery - ) - { - _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; - _groupRepository = groupRepository; - _collectionRepository = collectionRepository; - _organizationCiphersQuery = organizationCiphersQuery; - _applicationCacheService = applicationCacheService; - _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; - } - - public async Task> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request) - { - var orgUsers = await _organizationUserUserDetailsQuery.GetOrganizationUserUserDetails( - new OrganizationUserUserDetailsQueryRequest - { - OrganizationId = request.OrganizationId, - IncludeCollections = true, - IncludeGroups = true - }); - - var orgGroups = await _groupRepository.GetManyByOrganizationIdAsync(request.OrganizationId); - var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId); - var orgCollectionsWithAccess = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(request.OrganizationId); - var orgItems = await _organizationCiphersQuery.GetAllOrganizationCiphers(request.OrganizationId); - var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); - - var memberAccessCipherDetails = GenerateAccessDataParallel( - orgGroups, - orgCollectionsWithAccess, - orgItems, - organizationUsersTwoFactorEnabled, - orgAbility); - - return memberAccessCipherDetails; - } - - /// - /// Generates a report for all members of an organization. Containing summary information - /// such as item, collection, and group counts. Including the cipherIds a member is assigned. - /// Child collection includes detailed information on the user and group collections along - /// with their permissions. - /// - /// Organization groups collection - /// Collections for the organization and the groups/users and permissions - /// Cipher items for the organization with the collections associated with them - /// Organization users and two factor status - /// Organization ability for account recovery status - /// List of the MemberAccessCipherDetailsModel; - private IEnumerable GenerateAccessDataParallel( - ICollection orgGroups, - ICollection> orgCollectionsWithAccess, - IEnumerable orgItems, - IEnumerable<(OrganizationUserUserDetails user, bool twoFactorIsEnabled)> organizationUsersTwoFactorEnabled, - OrganizationAbility orgAbility) - { - var orgUsers = organizationUsersTwoFactorEnabled.Select(x => x.user).ToList(); - var groupNameDictionary = orgGroups.ToDictionary(x => x.Id, x => x.Name); - var collectionItems = orgItems - .SelectMany(x => x.CollectionIds, - (cipher, collectionId) => new { Cipher = cipher, CollectionId = collectionId }) - .GroupBy(y => y.CollectionId, - (key, ciphers) => new { CollectionId = key, Ciphers = ciphers }); - var itemLookup = collectionItems.ToDictionary(x => x.CollectionId.ToString(), x => x.Ciphers.Select(c => c.Cipher.Id.ToString()).ToList()); - - var memberAccessCipherDetails = new ConcurrentBag(); - - Parallel.ForEach(orgUsers, user => - { - var groupAccessDetails = new List(); - var userCollectionAccessDetails = new List(); - - foreach (var tCollect in orgCollectionsWithAccess) - { - if (itemLookup.TryGetValue(tCollect.Item1.Id.ToString(), out var items)) - { - var itemCounts = items.Count; - - if (tCollect.Item2.Groups.Any()) - { - var groupDetails = tCollect.Item2.Groups - .Where(tCollectGroups => user.Groups.Contains(tCollectGroups.Id)) - .Select(x => new MemberAccessDetails - { - CollectionId = tCollect.Item1.Id, - CollectionName = tCollect.Item1.Name, - GroupId = x.Id, - GroupName = groupNameDictionary[x.Id], - ReadOnly = x.ReadOnly, - HidePasswords = x.HidePasswords, - Manage = x.Manage, - ItemCount = itemCounts, - CollectionCipherIds = items - }); - - groupAccessDetails.AddRange(groupDetails); - } - - if (tCollect.Item2.Users.Any()) - { - var userCollectionDetails = tCollect.Item2.Users - .Where(tCollectUser => tCollectUser.Id == user.Id) - .Select(x => new MemberAccessDetails - { - CollectionId = tCollect.Item1.Id, - CollectionName = tCollect.Item1.Name, - ReadOnly = x.ReadOnly, - HidePasswords = x.HidePasswords, - Manage = x.Manage, - ItemCount = itemCounts, - CollectionCipherIds = items - }); - - userCollectionAccessDetails.AddRange(userCollectionDetails); - } - } - } - - var report = new MemberAccessCipherDetails - { - UserName = user.Name, - Email = user.Email, - TwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == user.Id).twoFactorIsEnabled, - AccountRecoveryEnabled = !string.IsNullOrEmpty(user.ResetPasswordKey) && orgAbility.UseResetPassword, - UserGuid = user.Id, - UsesKeyConnector = user.UsesKeyConnector - }; - - var userAccessDetails = new List(); - if (user.Groups.Any()) - { - var userGroups = groupAccessDetails.Where(x => user.Groups.Contains(x.GroupId.GetValueOrDefault())); - userAccessDetails.AddRange(userGroups); - } - - var groupsWithoutCollections = user.Groups.Where(x => !userAccessDetails.Any(y => x == y.GroupId)); - if (groupsWithoutCollections.Any()) - { - var emptyGroups = groupsWithoutCollections.Select(x => new MemberAccessDetails - { - GroupId = x, - GroupName = groupNameDictionary[x], - ItemCount = 0 - }); - userAccessDetails.AddRange(emptyGroups); - } - - if (user.Collections.Any()) - { - var userCollections = userCollectionAccessDetails.Where(x => user.Collections.Any(y => x.CollectionId == y.Id)); - userAccessDetails.AddRange(userCollections); - } - report.AccessDetails = userAccessDetails; - - var userCiphers = report.AccessDetails - .Where(x => x.ItemCount > 0) - .SelectMany(y => y.CollectionCipherIds) - .Distinct(); - report.CipherIds = userCiphers; - report.TotalItemCount = userCiphers.Count(); - - var distinctItems = report.AccessDetails.Where(x => x.CollectionId.HasValue).Select(x => x.CollectionId).Distinct(); - report.CollectionsCount = distinctItems.Count(); - report.GroupsCount = report.AccessDetails.Select(x => x.GroupId).Where(y => y.HasValue).Distinct().Count(); - - memberAccessCipherDetails.Add(report); - }); - - return memberAccessCipherDetails; - } -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs new file mode 100644 index 0000000000..33acd73d14 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs @@ -0,0 +1,67 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Dirt.Reports.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class MemberAccessReportQuery( + IOrganizationMemberBaseDetailRepository organizationMemberBaseDetailRepository, + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IApplicationCacheService applicationCacheService) : IMemberAccessReportQuery +{ + public async Task> GetMemberAccessReportsAsync( + MemberAccessReportRequest request) + { + var baseDetails = + await organizationMemberBaseDetailRepository.GetOrganizationMemberBaseDetailsByOrganizationId( + request.OrganizationId); + + var orgUsers = baseDetails.Select(x => x.UserGuid.GetValueOrDefault()).Distinct(); + var orgUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); + + var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId); + + var accessDetails = baseDetails + .GroupBy(b => new + { + b.UserGuid, + b.UserName, + b.Email, + b.TwoFactorProviders, + b.ResetPasswordKey, + b.UsesKeyConnector, + b.GroupId, + b.GroupName, + b.CollectionId, + b.CollectionName, + b.ReadOnly, + b.HidePasswords, + b.Manage + }) + .Select(g => new MemberAccessReportDetail + { + UserGuid = g.Key.UserGuid, + UserName = g.Key.UserName, + Email = g.Key.Email, + TwoFactorEnabled = orgUsersTwoFactorEnabled.FirstOrDefault(x => x.userId == g.Key.UserGuid).twoFactorIsEnabled, + AccountRecoveryEnabled = !string.IsNullOrWhiteSpace(g.Key.ResetPasswordKey) && orgAbility.UseResetPassword, + UsesKeyConnector = g.Key.UsesKeyConnector, + GroupId = g.Key.GroupId, + GroupName = g.Key.GroupName, + CollectionId = g.Key.CollectionId, + CollectionName = g.Key.CollectionName, + ReadOnly = g.Key.ReadOnly, + HidePasswords = g.Key.HidePasswords, + Manage = g.Key.Manage, + CipherIds = g.Select(c => c.CipherId) + }); + + return accessDetails; + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs deleted file mode 100644 index c55495fd13..0000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessCipherDetailsQuery.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.ReportFeatures.Requests; - -namespace Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces; - -public interface IMemberAccessCipherDetailsQuery -{ - Task> GetMemberAccessCipherDetails(MemberAccessCipherDetailsRequest request); -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessReportQuery.cs new file mode 100644 index 0000000000..44bb4f33c5 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IMemberAccessReportQuery.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Reports.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; + +public interface IMemberAccessReportQuery +{ + Task> GetMemberAccessReportsAsync(MemberAccessReportRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IRiskInsightsReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IRiskInsightsReportQuery.cs new file mode 100644 index 0000000000..c6ba69dfff --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/OrganizationReportMembers/Interfaces/IRiskInsightsReportQuery.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Reports.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; + +public interface IRiskInsightsReportQuery +{ + Task> GetRiskInsightsReportDetails(RiskInsightsReportRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index 4970f0515b..a20c7a3e8f 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -1,16 +1,20 @@ -using Bit.Core.Tools.ReportFeatures.Interfaces; -using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.Tools.ReportFeatures; +namespace Bit.Core.Dirt.Reports.ReportFeatures; public static class ReportingServiceCollectionExtensions { public static void AddReportingServices(this IServiceCollection services) { - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs new file mode 100644 index 0000000000..f5a3d581f2 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class AddOrganizationReportRequest +{ + public Guid OrganizationId { get; set; } + public string ReportData { get; set; } + public DateTime Date { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs index dfc544b1c3..884c7ea40a 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddPasswordHealthReportApplicationRequest.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Tools.ReportFeatures.Requests; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class AddPasswordHealthReportApplicationRequest { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/DropOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/DropOrganizationReportRequest.cs new file mode 100644 index 0000000000..04dc4b43a2 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/DropOrganizationReportRequest.cs @@ -0,0 +1,10 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class DropOrganizationReportRequest +{ + public Guid OrganizationId { get; set; } + public IEnumerable OrganizationReportIds { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs index 1464e68f04..3fc09af574 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/DropPasswordHealthReportApplicationRequest.cs @@ -1,4 +1,7 @@ -namespace Bit.Core.Tools.ReportFeatures.Requests; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class DropPasswordHealthReportApplicationRequest { diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs deleted file mode 100644 index 395230f430..0000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessCipherDetailsRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bit.Core.Tools.ReportFeatures.Requests; - -public class MemberAccessCipherDetailsRequest -{ - public Guid OrganizationId { get; set; } -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessReportRequest.cs new file mode 100644 index 0000000000..5fe28810a6 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/MemberAccessReportRequest.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class MemberAccessReportRequest +{ + public Guid OrganizationId { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/RiskInsightsReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/RiskInsightsReportRequest.cs new file mode 100644 index 0000000000..1b843ea002 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/RiskInsightsReportRequest.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class RiskInsightsReportRequest +{ + public Guid OrganizationId { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/RiskInsightsReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/RiskInsightsReportQuery.cs new file mode 100644 index 0000000000..e686698c51 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/RiskInsightsReportQuery.cs @@ -0,0 +1,39 @@ +using Bit.Core.Dirt.Reports.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Repositories; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class RiskInsightsReportQuery : IRiskInsightsReportQuery +{ + private readonly IOrganizationMemberBaseDetailRepository _organizationMemberBaseDetailRepository; + + public RiskInsightsReportQuery(IOrganizationMemberBaseDetailRepository repository) + { + _organizationMemberBaseDetailRepository = repository; + } + + public async Task> GetRiskInsightsReportDetails( + RiskInsightsReportRequest request) + { + var baseDetails = + await _organizationMemberBaseDetailRepository.GetOrganizationMemberBaseDetailsByOrganizationId( + request.OrganizationId); + + var insightsDetails = baseDetails + .GroupBy(b => new { b.UserGuid, b.UserName, b.Email, b.UsesKeyConnector }) + .Select(g => new RiskInsightsReportDetail + { + UserGuid = g.Key.UserGuid, + UserName = g.Key.UserName, + Email = g.Key.Email, + UsesKeyConnector = g.Key.UsesKeyConnector, + CipherIds = g + .Select(x => x.CipherId.ToString()) + .Distinct() + }); + + return insightsDetails; + } +} diff --git a/src/Core/Dirt/Repositories/IOrganizationApplicationRepository.cs b/src/Core/Dirt/Repositories/IOrganizationApplicationRepository.cs new file mode 100644 index 0000000000..f89e84e415 --- /dev/null +++ b/src/Core/Dirt/Repositories/IOrganizationApplicationRepository.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Repositories; + +namespace Bit.Core.Dirt.Repositories; + +public interface IOrganizationApplicationRepository : IRepository +{ + Task> GetByOrganizationIdAsync(Guid organizationId); +} diff --git a/src/Core/Dirt/Repositories/IOrganizationMemberBaseDetailRepository.cs b/src/Core/Dirt/Repositories/IOrganizationMemberBaseDetailRepository.cs new file mode 100644 index 0000000000..e2a161aa9c --- /dev/null +++ b/src/Core/Dirt/Repositories/IOrganizationMemberBaseDetailRepository.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Reports.Models.Data; + +namespace Bit.Core.Dirt.Reports.Repositories; + +public interface IOrganizationMemberBaseDetailRepository +{ + Task> GetOrganizationMemberBaseDetailsByOrganizationId(Guid organizationId); +} diff --git a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs new file mode 100644 index 0000000000..e7979ca4b7 --- /dev/null +++ b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs @@ -0,0 +1,12 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Repositories; + +namespace Bit.Core.Dirt.Repositories; + +public interface IOrganizationReportRepository : IRepository +{ + Task> GetByOrganizationIdAsync(Guid organizationId); + + Task GetLatestByOrganizationIdAsync(Guid organizationId); +} + diff --git a/src/Core/Dirt/Reports/Repositories/IPasswordHealthReportApplicationRepository.cs b/src/Core/Dirt/Repositories/IPasswordHealthReportApplicationRepository.cs similarity index 68% rename from src/Core/Dirt/Reports/Repositories/IPasswordHealthReportApplicationRepository.cs rename to src/Core/Dirt/Repositories/IPasswordHealthReportApplicationRepository.cs index 374f12e122..a67f696e44 100644 --- a/src/Core/Dirt/Reports/Repositories/IPasswordHealthReportApplicationRepository.cs +++ b/src/Core/Dirt/Repositories/IPasswordHealthReportApplicationRepository.cs @@ -1,7 +1,7 @@ -using Bit.Core.Repositories; -using Bit.Core.Tools.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Repositories; -namespace Bit.Core.Tools.Repositories; +namespace Bit.Core.Dirt.Repositories; public interface IPasswordHealthReportApplicationRepository : IRepository { diff --git a/src/Core/Entities/Collection.cs b/src/Core/Entities/Collection.cs index 8babe10e4c..275cd80d2f 100644 --- a/src/Core/Entities/Collection.cs +++ b/src/Core/Entities/Collection.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; using Bit.Core.Utilities; #nullable enable @@ -14,6 +15,8 @@ public class Collection : ITableObject public string? ExternalId { get; set; } public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public CollectionType Type { get; set; } = CollectionType.SharedCollection; + public string? DefaultUserCollectionEmail { get; set; } public void SetNewId() { diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index 08981ca2d3..b92d22b0e3 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -3,7 +3,6 @@ using System.Text.Json; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Enums; -using Bit.Core.Tools.Entities; using Bit.Core.Utilities; using Microsoft.AspNetCore.Identity; @@ -11,7 +10,7 @@ using Microsoft.AspNetCore.Identity; namespace Bit.Core.Entities; -public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser, IReferenceable +public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFactorProvidersUser { private Dictionary? _twoFactorProviders; @@ -196,12 +195,7 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public TwoFactorProvider? GetTwoFactorProvider(TwoFactorProviderType provider) { var providers = GetTwoFactorProviders(); - if (providers == null || !providers.TryGetValue(provider, out var value)) - { - return null; - } - - return value; + return providers?.GetValueOrDefault(provider); } public long StorageBytesRemaining() diff --git a/src/Core/Enums/BitwardenClient.cs b/src/Core/Enums/BitwardenClient.cs index 6a1244c0c4..4776e0de3f 100644 --- a/src/Core/Enums/BitwardenClient.cs +++ b/src/Core/Enums/BitwardenClient.cs @@ -8,5 +8,6 @@ public static class BitwardenClient Desktop = "desktop", Mobile = "mobile", Cli = "cli", - DirectoryConnector = "connector"; + DirectoryConnector = "connector", + Send = "send"; } diff --git a/src/Core/Enums/CollectionType.cs b/src/Core/Enums/CollectionType.cs new file mode 100644 index 0000000000..9bc4fcc9c2 --- /dev/null +++ b/src/Core/Enums/CollectionType.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Enums; + +public enum CollectionType +{ + SharedCollection = 0, + DefaultUserCollection = 1, +} diff --git a/src/Core/Enums/DeviceType.cs b/src/Core/Enums/DeviceType.cs index 9679088509..9f55f50bc0 100644 --- a/src/Core/Enums/DeviceType.cs +++ b/src/Core/Enums/DeviceType.cs @@ -55,5 +55,7 @@ public enum DeviceType : byte [Display(Name = "MacOs CLI")] MacOsCLI = 24, [Display(Name = "Linux CLI")] - LinuxCLI = 25 + LinuxCLI = 25, + [Display(Name = "DuckDuckGo")] + DuckDuckGoBrowser = 26, } diff --git a/src/Core/Enums/PushType.cs b/src/Core/Enums/PushType.cs index 96a1192478..07c40f94a2 100644 --- a/src/Core/Enums/PushType.cs +++ b/src/Core/Enums/PushType.cs @@ -31,5 +31,5 @@ public enum PushType : byte Notification = 20, NotificationStatus = 21, - PendingSecurityTasks = 22 + RefreshSecurityTasks = 22 } diff --git a/src/Core/Exceptions/BadRequestException.cs b/src/Core/Exceptions/BadRequestException.cs index 042f853a57..b27bc7510f 100644 --- a/src/Core/Exceptions/BadRequestException.cs +++ b/src/Core/Exceptions/BadRequestException.cs @@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Core.Exceptions; +#nullable enable + public class BadRequestException : Exception { public BadRequestException() : base() @@ -41,5 +43,5 @@ public class BadRequestException : Exception } } - public ModelStateDictionary ModelState { get; set; } + public ModelStateDictionary? ModelState { get; set; } } diff --git a/src/Core/Exceptions/ConflictException.cs b/src/Core/Exceptions/ConflictException.cs index 27b90a657f..92fcc52d7f 100644 --- a/src/Core/Exceptions/ConflictException.cs +++ b/src/Core/Exceptions/ConflictException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class ConflictException : Exception { public ConflictException() : base("Conflict.") { } diff --git a/src/Core/Exceptions/DnsQueryException.cs b/src/Core/Exceptions/DnsQueryException.cs index 57b2c56daa..e3f605dec4 100644 --- a/src/Core/Exceptions/DnsQueryException.cs +++ b/src/Core/Exceptions/DnsQueryException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class DnsQueryException : Exception { public DnsQueryException(string message) diff --git a/src/Core/Exceptions/DomainClaimedException.cs b/src/Core/Exceptions/DomainClaimedException.cs index 09ccb3d0d8..9ac6972fa1 100644 --- a/src/Core/Exceptions/DomainClaimedException.cs +++ b/src/Core/Exceptions/DomainClaimedException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class DomainClaimedException : Exception { public DomainClaimedException() diff --git a/src/Core/Exceptions/DomainVerifiedException.cs b/src/Core/Exceptions/DomainVerifiedException.cs index d3a3fd4de4..1fb704bd55 100644 --- a/src/Core/Exceptions/DomainVerifiedException.cs +++ b/src/Core/Exceptions/DomainVerifiedException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class DomainVerifiedException : Exception { public DomainVerifiedException() diff --git a/src/Core/Exceptions/DuplicateDomainException.cs b/src/Core/Exceptions/DuplicateDomainException.cs index 8d347dda55..4f61f333f5 100644 --- a/src/Core/Exceptions/DuplicateDomainException.cs +++ b/src/Core/Exceptions/DuplicateDomainException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class DuplicateDomainException : Exception { public DuplicateDomainException() diff --git a/src/Core/Exceptions/FeatureUnavailableException.cs b/src/Core/Exceptions/FeatureUnavailableException.cs index 7bea350956..80fd7d0635 100644 --- a/src/Core/Exceptions/FeatureUnavailableException.cs +++ b/src/Core/Exceptions/FeatureUnavailableException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + /// /// Exception to throw when a requested feature is not yet enabled/available for the requesting context. /// diff --git a/src/Core/Exceptions/GatewayException.cs b/src/Core/Exceptions/GatewayException.cs index 73e8cd7613..4b24c8d107 100644 --- a/src/Core/Exceptions/GatewayException.cs +++ b/src/Core/Exceptions/GatewayException.cs @@ -1,8 +1,10 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class GatewayException : Exception { - public GatewayException(string message, Exception innerException = null) + public GatewayException(string message, Exception? innerException = null) : base(message, innerException) { } } diff --git a/src/Core/Exceptions/InvalidEmailException.cs b/src/Core/Exceptions/InvalidEmailException.cs index 1f17acf62e..c38ec0ac38 100644 --- a/src/Core/Exceptions/InvalidEmailException.cs +++ b/src/Core/Exceptions/InvalidEmailException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class InvalidEmailException : Exception { public InvalidEmailException() diff --git a/src/Core/Exceptions/InvalidGatewayCustomerIdException.cs b/src/Core/Exceptions/InvalidGatewayCustomerIdException.cs index cfc7c56c1c..6ec15da308 100644 --- a/src/Core/Exceptions/InvalidGatewayCustomerIdException.cs +++ b/src/Core/Exceptions/InvalidGatewayCustomerIdException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class InvalidGatewayCustomerIdException : Exception { public InvalidGatewayCustomerIdException() diff --git a/src/Core/Exceptions/NotFoundException.cs b/src/Core/Exceptions/NotFoundException.cs index 70769d41ed..6a61e35868 100644 --- a/src/Core/Exceptions/NotFoundException.cs +++ b/src/Core/Exceptions/NotFoundException.cs @@ -1,5 +1,7 @@ namespace Bit.Core.Exceptions; +#nullable enable + public class NotFoundException : Exception { public NotFoundException() : base() diff --git a/src/Core/HostedServices/ApplicationCacheHostedService.cs b/src/Core/HostedServices/ApplicationCacheHostedService.cs index 9021782d20..ca2744bd10 100644 --- a/src/Core/HostedServices/ApplicationCacheHostedService.cs +++ b/src/Core/HostedServices/ApplicationCacheHostedService.cs @@ -10,9 +10,11 @@ using Microsoft.Extensions.Logging; namespace Bit.Core.HostedServices; +#nullable enable + public class ApplicationCacheHostedService : IHostedService, IDisposable { - private readonly InMemoryServiceBusApplicationCacheService _applicationCacheService; + private readonly InMemoryServiceBusApplicationCacheService? _applicationCacheService; private readonly IOrganizationRepository _organizationRepository; protected readonly ILogger _logger; private readonly ServiceBusClient _serviceBusClient; @@ -20,8 +22,8 @@ public class ApplicationCacheHostedService : IHostedService, IDisposable private readonly ServiceBusAdministrationClient _serviceBusAdministrationClient; private readonly string _subName; private readonly string _topicName; - private CancellationTokenSource _cts; - private Task _executingTask; + private CancellationTokenSource? _cts; + private Task? _executingTask; public ApplicationCacheHostedService( @@ -65,15 +67,25 @@ public class ApplicationCacheHostedService : IHostedService, IDisposable public virtual async Task StopAsync(CancellationToken cancellationToken) { + // Step 1: Signal ExecuteAsync to stop gracefully + _cts?.Cancel(); + + // Step 2: Wait for ExecuteAsync to finish cleanly + if (_executingTask != null) + { + await _executingTask; + } + + // Step 3: Now safely dispose resources (ExecuteAsync is done) await _subscriptionReceiver.CloseAsync(cancellationToken); await _serviceBusClient.DisposeAsync(); - _cts.Cancel(); + + // Step 4: Clean up subscription try { await _serviceBusAdministrationClient.DeleteSubscriptionAsync(_topicName, _subName, cancellationToken); } catch { } - await _executingTask; } public virtual void Dispose() @@ -81,15 +93,39 @@ public class ApplicationCacheHostedService : IHostedService, IDisposable private async Task ExecuteAsync(CancellationToken cancellationToken) { - await foreach (var message in _subscriptionReceiver.ReceiveMessagesAsync(cancellationToken)) + while (!cancellationToken.IsCancellationRequested) { try { - await ProcessMessageAsync(message, cancellationToken); + var messages = await _subscriptionReceiver.ReceiveMessagesAsync( + maxMessages: 1, + maxWaitTime: TimeSpan.FromSeconds(30), + cancellationToken); + + if (messages?.Any() == true) + { + foreach (var message in messages) + { + try + { + await ProcessMessageAsync(message, cancellationToken); + } + catch (Exception e) + { + _logger.LogError(e, "Error processing messages in ApplicationCacheHostedService"); + } + } + } } - catch (Exception e) + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) { - _logger.LogError(e, "Error processing messages in ApplicationCacheHostedService"); + _logger.LogDebug("ServiceBus receiver disposed during Alpine container shutdown"); + break; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug("ServiceBus operation cancelled during Alpine container shutdown"); + break; } } } diff --git a/src/Core/HostedServices/IpRateLimitSeedStartupService.cs b/src/Core/HostedServices/IpRateLimitSeedStartupService.cs index a6869d929c..827dd94806 100644 --- a/src/Core/HostedServices/IpRateLimitSeedStartupService.cs +++ b/src/Core/HostedServices/IpRateLimitSeedStartupService.cs @@ -3,6 +3,8 @@ using Microsoft.Extensions.Hosting; namespace Bit.Core.HostedServices; +#nullable enable + /// /// A startup service that will seed the IP rate limiting stores with any values in the /// GlobalSettings configuration. diff --git a/src/Core/Identity/Claims.cs b/src/Core/Identity/Claims.cs index fad7b37b5f..ef3d5e450c 100644 --- a/src/Core/Identity/Claims.cs +++ b/src/Core/Identity/Claims.cs @@ -39,4 +39,6 @@ public static class Claims public const string ManageResetPassword = "manageresetpassword"; public const string ManageScim = "managescim"; } + + public const string SendId = "send_id"; } diff --git a/src/Core/Identity/CustomIdentityServiceCollectionExtensions.cs b/src/Core/Identity/CustomIdentityServiceCollectionExtensions.cs index 01914540ac..f313e8995c 100644 --- a/src/Core/Identity/CustomIdentityServiceCollectionExtensions.cs +++ b/src/Core/Identity/CustomIdentityServiceCollectionExtensions.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Identity; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Identity; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/src/Core/Identity/IdentityClientType.cs b/src/Core/Identity/IdentityClientType.cs index bd5b68ff6f..9c43007f25 100644 --- a/src/Core/Identity/IdentityClientType.cs +++ b/src/Core/Identity/IdentityClientType.cs @@ -5,4 +5,5 @@ public enum IdentityClientType : byte User = 0, Organization = 1, ServiceAccount = 2, + Send = 3 } diff --git a/src/Core/IdentityServer/ApiScopes.cs b/src/Core/IdentityServer/ApiScopes.cs index 6e3ce0d140..77ccb5a58a 100644 --- a/src/Core/IdentityServer/ApiScopes.cs +++ b/src/Core/IdentityServer/ApiScopes.cs @@ -11,6 +11,7 @@ public static class ApiScopes public const string ApiPush = "api.push"; public const string ApiSecrets = "api.secrets"; public const string Internal = "internal"; + public const string ApiSendAccess = "api.send.access"; public static IEnumerable GetApiScopes() { @@ -23,6 +24,7 @@ public static class ApiScopes new(ApiInstallation, "API Installation Access"), new(Internal, "Internal Access"), new(ApiSecrets, "Secrets Manager Access"), + new(ApiSendAccess, "API Send Access"), }; } } diff --git a/src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs b/src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs index cbb91a1e72..381f81dea5 100644 --- a/src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs +++ b/src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs @@ -1,4 +1,7 @@ -using Duende.IdentityServer.Configuration; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Duende.IdentityServer.Configuration; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Distributed; diff --git a/src/Core/IdentityServer/DistributedCacheCookieManager.cs b/src/Core/IdentityServer/DistributedCacheCookieManager.cs index 9771b40662..a01ff63d8f 100644 --- a/src/Core/IdentityServer/DistributedCacheCookieManager.cs +++ b/src/Core/IdentityServer/DistributedCacheCookieManager.cs @@ -1,4 +1,7 @@ -using System.Text; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using System.Text; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Distributed; @@ -63,6 +66,6 @@ public class DistributedCacheCookieManager : ICookieManager private string GetKey(string key, string id) => $"{CacheKeyPrefix}-{key}-{id}"; private string GetId(HttpContext context, string key) => - context.Request.Cookies.ContainsKey(key) ? - context.Request.Cookies[key] : null; + context.Request.Cookies.TryGetValue(key, out var cookie) ? + cookie : null; } diff --git a/src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs b/src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs index 6a4b7439d4..ad3fdee6f0 100644 --- a/src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs +++ b/src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Authentication; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Distributed; diff --git a/src/Core/IdentityServer/DistributedCacheTicketStore.cs b/src/Core/IdentityServer/DistributedCacheTicketStore.cs index 949c1173cc..ddf66f04ec 100644 --- a/src/Core/IdentityServer/DistributedCacheTicketStore.cs +++ b/src/Core/IdentityServer/DistributedCacheTicketStore.cs @@ -1,4 +1,7 @@ -using Microsoft.AspNetCore.Authentication; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Caching.Distributed; diff --git a/src/Core/Jobs/BaseJob.cs b/src/Core/Jobs/BaseJob.cs index 56c39014a7..a56045f659 100644 --- a/src/Core/Jobs/BaseJob.cs +++ b/src/Core/Jobs/BaseJob.cs @@ -3,6 +3,8 @@ using Quartz; namespace Bit.Core.Jobs; +#nullable enable + public abstract class BaseJob : IJob { protected readonly ILogger _logger; diff --git a/src/Core/Jobs/BaseJobsHostedService.cs b/src/Core/Jobs/BaseJobsHostedService.cs index 897a382a2b..3e7bce7e0f 100644 --- a/src/Core/Jobs/BaseJobsHostedService.cs +++ b/src/Core/Jobs/BaseJobsHostedService.cs @@ -8,6 +8,8 @@ using Quartz.Impl.Matchers; namespace Bit.Core.Jobs; +#nullable enable + public abstract class BaseJobsHostedService : IHostedService, IDisposable { private const int MaximumJobRetries = 10; @@ -16,7 +18,7 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable private readonly ILogger _listenerLogger; protected readonly ILogger _logger; - private IScheduler _scheduler; + private IScheduler? _scheduler; protected GlobalSettings _globalSettings; public BaseJobsHostedService( @@ -31,7 +33,7 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable _globalSettings = globalSettings; } - public IEnumerable> Jobs { get; protected set; } + public IEnumerable>? Jobs { get; protected set; } public virtual async Task StartAsync(CancellationToken cancellationToken) { @@ -61,10 +63,19 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable _scheduler.ListenerManager.AddJobListener(new JobListener(_listenerLogger), GroupMatcher.AnyGroup()); await _scheduler.Start(cancellationToken); + + var jobKeys = new List(); + var triggerKeys = new List(); + if (Jobs != null) { foreach (var (job, trigger) in Jobs) { + jobKeys.Add(JobBuilder.Create(job) + .WithIdentity(job.FullName!) + .Build().Key); + triggerKeys.Add(trigger.Key); + for (var retry = 0; retry < MaximumJobRetries; retry++) { // There's a race condition when starting multiple containers simultaneously, retry until it succeeds.. @@ -77,7 +88,7 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable } var jobDetail = JobBuilder.Create(job) - .WithIdentity(job.FullName) + .WithIdentity(job.FullName!) .Build(); var dupeJ = await _scheduler.GetJobDetail(jobDetail.Key); @@ -98,7 +109,7 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable _logger.LogWarning($"Exception while trying to schedule job: {job.FullName}, {e}"); var random = new Random(); - Thread.Sleep(random.Next(50, 250)); + await Task.Delay(random.Next(50, 250)); } } } @@ -106,13 +117,6 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable // Delete old Jobs and Triggers var existingJobKeys = await _scheduler.GetJobKeys(GroupMatcher.AnyGroup()); - var jobKeys = Jobs.Select(j => - { - var job = j.Item1; - return JobBuilder.Create(job) - .WithIdentity(job.FullName) - .Build().Key; - }); foreach (var key in existingJobKeys) { @@ -126,7 +130,6 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable } var existingTriggerKeys = await _scheduler.GetTriggerKeys(GroupMatcher.AnyGroup()); - var triggerKeys = Jobs.Select(j => j.Item2.Key); foreach (var key in existingTriggerKeys) { @@ -142,7 +145,10 @@ public abstract class BaseJobsHostedService : IHostedService, IDisposable public virtual async Task StopAsync(CancellationToken cancellationToken) { - await _scheduler?.Shutdown(cancellationToken); + if (_scheduler is not null) + { + await _scheduler.Shutdown(cancellationToken); + } } public virtual void Dispose() diff --git a/src/Core/Jobs/JobFactory.cs b/src/Core/Jobs/JobFactory.cs index 6529443d97..8289a90322 100644 --- a/src/Core/Jobs/JobFactory.cs +++ b/src/Core/Jobs/JobFactory.cs @@ -4,6 +4,8 @@ using Quartz.Spi; namespace Bit.Core.Jobs; +#nullable enable + public class JobFactory : IJobFactory { private readonly IServiceProvider _container; @@ -16,7 +18,7 @@ public class JobFactory : IJobFactory public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler) { var scope = _container.CreateScope(); - return scope.ServiceProvider.GetService(bundle.JobDetail.JobType) as IJob; + return (scope.ServiceProvider.GetService(bundle.JobDetail.JobType) as IJob)!; } public void ReturnJob(IJob job) diff --git a/src/Core/Jobs/JobListener.cs b/src/Core/Jobs/JobListener.cs index e5e05e4b6b..0dc865655d 100644 --- a/src/Core/Jobs/JobListener.cs +++ b/src/Core/Jobs/JobListener.cs @@ -3,6 +3,8 @@ using Quartz; namespace Bit.Core.Jobs; +#nullable enable + public class JobListener : IJobListener { private readonly ILogger _logger; @@ -28,7 +30,7 @@ public class JobListener : IJobListener return Task.FromResult(0); } - public Task JobWasExecuted(IJobExecutionContext context, JobExecutionException jobException, + public Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default(CancellationToken)) { _logger.LogInformation(Constants.BypassFiltersEventId, null, "Finished job {0} at {1}.", diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index 102630c7e6..cacf3d4140 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ public static class KeyManagementServiceCollectionExtensions public static void AddKeyManagementServices(this IServiceCollection services) { services.AddKeyManagementCommands(); + services.AddSendPasswordServices(); } private static void AddKeyManagementCommands(this IServiceCollection services) diff --git a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs index f81baf6fab..b89f19797f 100644 --- a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs +++ b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; using Bit.Core.Tools.Entities; diff --git a/src/Core/KeyManagement/Models/Data/RotateUserKeyData.cs b/src/Core/KeyManagement/Models/Data/RotateUserKeyData.cs deleted file mode 100644 index 9813f760f3..0000000000 --- a/src/Core/KeyManagement/Models/Data/RotateUserKeyData.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Bit.Core.Auth.Entities; -using Bit.Core.Auth.Models.Data; -using Bit.Core.Entities; -using Bit.Core.Tools.Entities; -using Bit.Core.Vault.Entities; - -namespace Bit.Core.KeyManagement.Models.Data; - -public class RotateUserKeyData -{ - public string MasterPasswordHash { get; set; } - public string Key { get; set; } - public string PrivateKey { get; set; } - public IEnumerable Ciphers { get; set; } - public IEnumerable Folders { get; set; } - public IReadOnlyList Sends { get; set; } - public IEnumerable EmergencyAccesses { get; set; } - public IReadOnlyList OrganizationUsers { get; set; } - public IEnumerable WebAuthnKeys { get; set; } -} diff --git a/src/Core/KeyManagement/Models/Response/MasterPasswordUnlockResponseModel.cs b/src/Core/KeyManagement/Models/Response/MasterPasswordUnlockResponseModel.cs new file mode 100644 index 0000000000..f7d5dee852 --- /dev/null +++ b/src/Core/KeyManagement/Models/Response/MasterPasswordUnlockResponseModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Core.KeyManagement.Models.Response; + +public class MasterPasswordUnlockResponseModel +{ + public required MasterPasswordUnlockKdfResponseModel Kdf { get; init; } + [EncryptedString] public required string MasterKeyEncryptedUserKey { get; init; } + [StringLength(256)] public required string Salt { get; init; } +} + +public class MasterPasswordUnlockKdfResponseModel +{ + public required KdfType KdfType { get; init; } + public required int Iterations { get; init; } + public int? Memory { get; init; } + public int? Parallelism { get; init; } +} diff --git a/src/Core/KeyManagement/Models/Response/UserDecryptionResponseModel.cs b/src/Core/KeyManagement/Models/Response/UserDecryptionResponseModel.cs new file mode 100644 index 0000000000..a4d259a00a --- /dev/null +++ b/src/Core/KeyManagement/Models/Response/UserDecryptionResponseModel.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.KeyManagement.Models.Response; + +public class UserDecryptionResponseModel +{ + /// + /// Returns the unlock data when the user has a master password that can be used to decrypt their vault. + /// + public MasterPasswordUnlockResponseModel? MasterPasswordUnlock { get; set; } +} diff --git a/src/Core/KeyManagement/Sends/ISendPasswordHasher.cs b/src/Core/KeyManagement/Sends/ISendPasswordHasher.cs new file mode 100644 index 0000000000..b84be5abc0 --- /dev/null +++ b/src/Core/KeyManagement/Sends/ISendPasswordHasher.cs @@ -0,0 +1,20 @@ +namespace Bit.Core.KeyManagement.Sends; + +public interface ISendPasswordHasher +{ + /// + /// Matches the send password hash against the user provided client password hash. The send password is server hashed and the client + /// password hash is hashed by the server for comparison in this method. + /// + /// The send password that is hashed by the server. + /// The user provided password hash that has not yet been hashed by the server for comparison. + /// true if hashes match false otherwise + bool PasswordHashMatches(string sendPasswordHash, string clientPasswordHash); + + /// + /// Accepts a client hashed send password and returns a server hashed password. + /// + /// + /// server hashed password + string HashOfClientPasswordHash(string clientHashedPassword); +} diff --git a/src/Core/KeyManagement/Sends/SendPasswordHasher.cs b/src/Core/KeyManagement/Sends/SendPasswordHasher.cs new file mode 100644 index 0000000000..abe57d3cc6 --- /dev/null +++ b/src/Core/KeyManagement/Sends/SendPasswordHasher.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.Sends; + +internal class SendPasswordHasher(IPasswordHasher passwordHasher) : ISendPasswordHasher +{ + private readonly IPasswordHasher _passwordHasher = passwordHasher; + + /// + /// + /// + public bool PasswordHashMatches(string sendPasswordHash, string inputPasswordHash) + { + if (string.IsNullOrWhiteSpace(sendPasswordHash) || string.IsNullOrWhiteSpace(inputPasswordHash)) + { + return false; + } + + var passwordResult = _passwordHasher.VerifyHashedPassword(SendPasswordHasherMarker.Instance, sendPasswordHash, inputPasswordHash); + + /* + In our use-case we input a high-entropy, pre-hashed secret sent by the client. Thus, we don't really care + about if the hash needs to be rehashed. Sends also only live for 30 days max. + */ + return passwordResult is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded; + } + + /// + /// + /// + public string HashOfClientPasswordHash(string clientHashedPassword) + { + return _passwordHasher.HashPassword(SendPasswordHasherMarker.Instance, clientHashedPassword); + } +} diff --git a/src/Core/KeyManagement/Sends/SendPasswordHasherMarker.cs b/src/Core/KeyManagement/Sends/SendPasswordHasherMarker.cs new file mode 100644 index 0000000000..d4b80a09a2 --- /dev/null +++ b/src/Core/KeyManagement/Sends/SendPasswordHasherMarker.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.KeyManagement.Sends; + +// This should not be used except for DI as open generic marker class for use with +// the SendPasswordHasher. +public class SendPasswordHasherMarker +{ + // We know we will pass a single instance that isn't used to the PasswordHasher so we + // gain an efficiency benefit of not creating multiple marker classes. + public static readonly SendPasswordHasherMarker Instance = new(); +} diff --git a/src/Core/KeyManagement/Sends/SendPasswordHasherServiceCollectionExtensions.cs b/src/Core/KeyManagement/Sends/SendPasswordHasherServiceCollectionExtensions.cs new file mode 100644 index 0000000000..22939ce60c --- /dev/null +++ b/src/Core/KeyManagement/Sends/SendPasswordHasherServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using Bit.Core.Auth.UserFeatures.PasswordValidation; +using Bit.Core.KeyManagement.Sends; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +using Microsoft.Extensions.Options; + +public static class SendPasswordHasherServiceCollectionExtensions +{ + public static void AddSendPasswordServices(this IServiceCollection services) + { + const string sendPasswordHasherMarkerName = "SendPasswordHasherMarker"; + + services.AddOptions(sendPasswordHasherMarkerName) + .Configure(options => options.IterationCount = PasswordValidationConstants.PasswordHasherKdfIterations); + + services.TryAddScoped>(sp => + { + var opts = sp + .GetRequiredService>() + .Get(sendPasswordHasherMarkerName); + + var optionsAccessor = Options.Create(opts); + + return new PasswordHasher(optionsAccessor); + }); + services.TryAddScoped(); + } +} diff --git a/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs b/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs index ec40e7031d..c8bd7cab1f 100644 --- a/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs +++ b/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs @@ -1,6 +1,10 @@ -using Bit.Core.Entities; +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Entities; using Bit.Core.KeyManagement.Models.Data; using Microsoft.AspNetCore.Identity; +using Microsoft.Data.SqlClient; namespace Bit.Core.KeyManagement.UserKey; @@ -18,3 +22,12 @@ public interface IRotateUserAccountKeysCommand /// User KDF settings and email must match the model provided settings. Task RotateUserAccountKeysAsync(User user, RotateUserAccountKeysData model); } + +/// +/// A type used to implement updates to the database for key rotations. Each domain that requires an update of encrypted +/// data during a key rotation should use this to implement its own database call. The user repository loops through +/// these during a key rotation. +/// Note: connection and transaction are only used for Dapper. They won't be available in EF +/// +public delegate Task UpdateEncryptedDataForKeyRotation(SqlConnection connection = null, + SqlTransaction transaction = null); diff --git a/src/Core/KeyManagement/UserKey/IRotateUserKeyCommand.cs b/src/Core/KeyManagement/UserKey/IRotateUserKeyCommand.cs deleted file mode 100644 index 90dc90541f..0000000000 --- a/src/Core/KeyManagement/UserKey/IRotateUserKeyCommand.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Bit.Core.Entities; -using Bit.Core.KeyManagement.Models.Data; -using Microsoft.AspNetCore.Identity; -using Microsoft.Data.SqlClient; - -namespace Bit.Core.KeyManagement.UserKey; - -/// -/// Responsible for rotation of a user key and updating database with re-encrypted data -/// -public interface IRotateUserKeyCommand -{ - /// - /// Sets a new user key and updates all encrypted data. - /// - /// All necessary information for rotation. Warning: Any encrypted data not included will be lost. - /// An IdentityResult for verification of the master password hash - /// User must be provided. - Task RotateUserKeyAsync(User user, RotateUserKeyData model); -} - -/// -/// A type used to implement updates to the database for key rotations. Each domain that requires an update of encrypted -/// data during a key rotation should use this to implement its own database call. The user repository loops through -/// these during a key rotation. -/// Note: connection and transaction are only used for Dapper. They won't be available in EF -/// -public delegate Task UpdateEncryptedDataForKeyRotation(SqlConnection connection = null, - SqlTransaction transaction = null); diff --git a/src/Core/KeyManagement/UserKey/Implementations/RotateUserKeyCommand.cs b/src/Core/KeyManagement/UserKey/Implementations/RotateUserKeyCommand.cs deleted file mode 100644 index 8cece5f762..0000000000 --- a/src/Core/KeyManagement/UserKey/Implementations/RotateUserKeyCommand.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Bit.Core.Auth.Repositories; -using Bit.Core.Entities; -using Bit.Core.KeyManagement.Models.Data; -using Bit.Core.Platform.Push; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Core.Tools.Repositories; -using Bit.Core.Vault.Repositories; -using Microsoft.AspNetCore.Identity; - -namespace Bit.Core.KeyManagement.UserKey.Implementations; - -/// -public class RotateUserKeyCommand : IRotateUserKeyCommand -{ - private readonly IUserService _userService; - private readonly IUserRepository _userRepository; - private readonly ICipherRepository _cipherRepository; - private readonly IFolderRepository _folderRepository; - private readonly ISendRepository _sendRepository; - private readonly IEmergencyAccessRepository _emergencyAccessRepository; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IPushNotificationService _pushService; - private readonly IdentityErrorDescriber _identityErrorDescriber; - private readonly IWebAuthnCredentialRepository _credentialRepository; - - /// - /// Instantiates a new - /// - /// Master password hash validation - /// Updates user keys and re-encrypted data if needed - /// Provides a method to update re-encrypted cipher data - /// Provides a method to update re-encrypted folder data - /// Provides a method to update re-encrypted send data - /// Provides a method to update re-encrypted emergency access data - /// Logs out user from other devices after successful rotation - /// Provides a password mismatch error if master password hash validation fails - public RotateUserKeyCommand(IUserService userService, IUserRepository userRepository, - ICipherRepository cipherRepository, IFolderRepository folderRepository, ISendRepository sendRepository, - IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository, - IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository) - { - _userService = userService; - _userRepository = userRepository; - _cipherRepository = cipherRepository; - _folderRepository = folderRepository; - _sendRepository = sendRepository; - _emergencyAccessRepository = emergencyAccessRepository; - _organizationUserRepository = organizationUserRepository; - _pushService = pushService; - _identityErrorDescriber = errors; - _credentialRepository = credentialRepository; - } - - /// - public async Task RotateUserKeyAsync(User user, RotateUserKeyData model) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (!await _userService.CheckPasswordAsync(user, model.MasterPasswordHash)) - { - return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); - } - - var now = DateTime.UtcNow; - user.RevisionDate = user.AccountRevisionDate = now; - user.LastKeyRotationDate = now; - user.SecurityStamp = Guid.NewGuid().ToString(); - user.Key = model.Key; - user.PrivateKey = model.PrivateKey; - if (model.Ciphers.Any() || model.Folders.Any() || model.Sends.Any() || model.EmergencyAccesses.Any() || - model.OrganizationUsers.Any() || model.WebAuthnKeys.Any()) - { - List saveEncryptedDataActions = new(); - - if (model.Ciphers.Any()) - { - saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers)); - } - - if (model.Folders.Any()) - { - saveEncryptedDataActions.Add(_folderRepository.UpdateForKeyRotation(user.Id, model.Folders)); - } - - if (model.Sends.Any()) - { - saveEncryptedDataActions.Add(_sendRepository.UpdateForKeyRotation(user.Id, model.Sends)); - } - - if (model.EmergencyAccesses.Any()) - { - saveEncryptedDataActions.Add( - _emergencyAccessRepository.UpdateForKeyRotation(user.Id, model.EmergencyAccesses)); - } - - if (model.OrganizationUsers.Any()) - { - saveEncryptedDataActions.Add( - _organizationUserRepository.UpdateForKeyRotation(user.Id, model.OrganizationUsers)); - } - - if (model.WebAuthnKeys.Any()) - { - saveEncryptedDataActions.Add(_credentialRepository.UpdateKeysForRotationAsync(user.Id, model.WebAuthnKeys)); - } - - await _userRepository.UpdateUserKeyAndEncryptedDataAsync(user, saveEncryptedDataActions); - } - else - { - await _userRepository.ReplaceAsync(user); - } - - await _pushService.PushLogOutAsync(user.Id, excludeCurrentContextFromPush: true); - return IdentityResult.Success; - } -} diff --git a/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.html.hbs new file mode 100644 index 0000000000..56052c7a0d --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.html.hbs @@ -0,0 +1,37 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + + + + + +
+ We've detected a failed login attempt +
+
+ If you're having trouble with two-step login, please visit the Help Center. +
+
+ If you did not recently try to log in, open the web app and take these immediate steps to secure your Bitwarden account: +
    +
  • Deauthorize all devices
  • +
  • Change your master password
  • +
+
+
+
+
+ Account: {{AffectedEmail}}
+ Two-Step Login Method: {{TwoFactorType}}
+ Date: {{TheDate}} at {{TheTime}} {{TimeZone}}
+ IP Address: {{IpAddress}}
+
+{{/FullHtmlLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.text.hbs new file mode 100644 index 0000000000..4ad5dd32a3 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/FailedTwoFactorAttempt.text.hbs @@ -0,0 +1,18 @@ +{{#>BasicTextLayout}} +We've detected a failed login attempt + +If you're having trouble with two-step login, please visit the Help Center (https://bitwarden.com/help/). + +If you did not recently try to log in, open the web app ({{{WebVaultUrl}}}) and take these immediate steps to secure your Bitwarden account: +- Deauthorize all devices +- Change your master password + +Account: {{AffectedEmail}} +Two-Step Login Method: {{TwoFactorType}} +Date: {{TheDate}} at {{TheTime}} {{TimeZone}} +IP Address: {{IpAddress}} + +{{/BasicTextLayout}} + + + diff --git a/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.html.hbs index 27a222f1de..7add179787 100644 --- a/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Auth/TwoFactorEmail.html.hbs @@ -12,7 +12,9 @@
  • Deauthorize unrecognized devices
  • Change your master password
  • -
  • Turn on two-step login
  • + {{#if DisplayTwoFactorReminder}} +
  • Turn on two-step login
  • + {{/if}}
diff --git a/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs b/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs index 7ed9fb7d1a..bcf6be62c9 100644 --- a/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Layouts/Full.html.hbs @@ -148,7 +148,7 @@ - + diff --git a/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs b/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs index bf4ec50796..72f669bf34 100644 --- a/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs +++ b/src/Core/MailTemplates/Handlebars/Layouts/Full.text.hbs @@ -2,7 +2,7 @@ ---------------------------- -- Twitter: https://twitter.com/bitwarden +- X: https://x.com/bitwarden - Reddit: https://www.reddit.com/r/Bitwarden/ - Community Forums: https://community.bitwarden.com/ - GitHub: https://github.com/bitwarden diff --git a/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs b/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs index f5772d61f6..f79e5f7043 100644 --- a/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Layouts/FullUpdated.html.hbs @@ -177,7 +177,7 @@
TwitterX Reddit CommunityForums GitHub