diff --git a/.github/workflows/_move_edd_db_scripts.yml b/.github/workflows/_move_edd_db_scripts.yml index b38a3e0dff..7e97fa2a07 100644 --- a/.github/workflows/_move_edd_db_scripts.yml +++ b/.github/workflows/_move_edd_db_scripts.yml @@ -41,18 +41,19 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + persist-credentials: false - name: Get script prefix id: prefix - run: echo "prefix=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + run: echo "prefix=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT" - name: Check if any files in DB transition or finalization directories id: check-script-existence run: | if [ -f util/Migrator/DbScripts_transition/* -o -f util/Migrator/DbScripts_finalization/* ]; then - echo "copy_edd_scripts=true" >> $GITHUB_OUTPUT + echo "copy_edd_scripts=true" >> "$GITHUB_OUTPUT" else - echo "copy_edd_scripts=false" >> $GITHUB_OUTPUT + echo "copy_edd_scripts=false" >> "$GITHUB_OUTPUT" fi move-scripts: @@ -70,17 +71,18 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: true - name: Generate branch name id: branch_name env: PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }} - run: echo "branch_name=move_edd_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 + run: git switch -c "$BRANCH" - name: Move scripts and finalization database schema id: move-files @@ -120,7 +122,7 @@ jobs: # sync finalization schema back to dbo, maintaining structure rsync -r "$src_dir/" "$dest_dir/" - rm -rf $src_dir/* + rm -rf "${src_dir}"/* # Replace any finalization references due to the move find ./src/Sql/dbo -name "*.sql" -type f -exec sed -i \ @@ -131,7 +133,7 @@ jobs: moved_files="$moved_files \n $file" done - echo "moved_files=$moved_files" >> $GITHUB_OUTPUT + echo "moved_files=$moved_files" >> "$GITHUB_OUTPUT" - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -162,18 +164,20 @@ jobs: - name: Commit and push changes id: commit + env: + BRANCH_NAME: ${{ steps.branch_name.outputs.branch_name }} run: | git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com" git config --local user.name "bitwarden-devops-bot" if [ -n "$(git status --porcelain)" ]; then git add . git commit -m "Move EDD database scripts" -a - git push -u origin ${{ steps.branch_name.outputs.branch_name }} - echo "pr_needed=true" >> $GITHUB_OUTPUT + git push -u origin "${BRANCH_NAME}" + echo "pr_needed=true" >> "$GITHUB_OUTPUT" else echo "No changes to commit!"; - echo "pr_needed=false" >> $GITHUB_OUTPUT - echo "### :mega: No changes to commit! PR was ommited." >> $GITHUB_STEP_SUMMARY + echo "pr_needed=false" >> "$GITHUB_OUTPUT" + echo "### :mega: No changes to commit! PR was ommited." >> "$GITHUB_STEP_SUMMARY" fi - name: Create PR for ${{ steps.branch_name.outputs.branch_name }} @@ -195,7 +199,7 @@ jobs: Files moved: $(echo -e "$MOVED_FILES") ") - echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT + echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT" - name: Notify Slack about creation of PR if: ${{ steps.commit.outputs.pr_needed == 'true' }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 907f50197b..49cd81d28f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,6 +28,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 @@ -97,23 +98,24 @@ jobs: id: check-secrets run: | has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} - echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT + 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 }} + persist-credentials: false - name: Check branch to publish env: PUBLISH_BRANCHES: "main,rc,hotfix-rc" id: publish-branch-check run: | - IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES + IFS="," read -a publish_branches <<< "$PUBLISH_BRANCHES" if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then - echo "is_publish_branch=true" >> $GITHUB_ENV + echo "is_publish_branch=true" >> "$GITHUB_ENV" else - echo "is_publish_branch=false" >> $GITHUB_ENV + echo "is_publish_branch=false" >> "$GITHUB_ENV" fi - name: Set up .NET @@ -209,8 +211,8 @@ jobs: IMAGE_TAG=dev fi - echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT - echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> $GITHUB_STEP_SUMMARY + echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT" + echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> "$GITHUB_STEP_SUMMARY" - name: Set up project name id: setup @@ -218,7 +220,7 @@ jobs: PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}') echo "Matrix name: ${{ matrix.project_name }}" echo "PROJECT_NAME: $PROJECT_NAME" - echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT + echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT" - name: Generate image tags(s) id: image-tags @@ -228,12 +230,12 @@ jobs: SHA: ${{ github.sha }} run: | TAGS="${_AZ_REGISTRY}/${PROJECT_NAME}:${IMAGE_TAG}" - echo "primary_tag=$TAGS" >> $GITHUB_OUTPUT + echo "primary_tag=$TAGS" >> "$GITHUB_OUTPUT" if [[ "${IMAGE_TAG}" == "dev" ]]; then - SHORT_SHA=$(git rev-parse --short ${SHA}) + SHORT_SHA=$(git rev-parse --short "${SHA}") TAGS=$TAGS",${_AZ_REGISTRY}/${PROJECT_NAME}:dev-${SHORT_SHA}" fi - echo "tags=$TAGS" >> $GITHUB_OUTPUT + echo "tags=$TAGS" >> "$GITHUB_OUTPUT" - name: Build Docker image id: build-artifacts @@ -260,12 +262,13 @@ jobs: DIGEST: ${{ steps.build-artifacts.outputs.digest }} TAGS: ${{ steps.image-tags.outputs.tags }} run: | - IFS="," read -a tags <<< "${TAGS}" - images="" - for tag in "${tags[@]}"; do - images+="${tag}@${DIGEST} " + IFS=',' read -r -a tags_array <<< "${TAGS}" + images=() + for tag in "${tags_array[@]}"; do + images+=("${tag}@${DIGEST}") done - cosign sign --yes ${images} + cosign sign --yes ${images[@]} + echo "images=${images[*]}" >> "$GITHUB_OUTPUT" - name: Scan Docker image id: container-scan @@ -297,6 +300,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 @@ -309,7 +313,7 @@ jobs: client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to ACR - production subscription - run: az acr login -n $_AZ_REGISTRY --only-show-errors + run: az acr login -n "$_AZ_REGISTRY" --only-show-errors - name: Make Docker stubs if: | @@ -332,26 +336,26 @@ jobs: STUB_OUTPUT=$(pwd)/docker-stub # Run setup - docker run -i --rm --name setup -v $STUB_OUTPUT/US:/bitwarden $SETUP_IMAGE \ + docker run -i --rm --name setup -v "$STUB_OUTPUT/US:/bitwarden" "$SETUP_IMAGE" \ /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 \ + docker run -i --rm --name setup -v "$STUB_OUTPUT/EU:/bitwarden" "$SETUP_IMAGE" \ /app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU - sudo chown -R $(whoami):$(whoami) $STUB_OUTPUT + sudo chown -R "$(whoami):$(whoami)" "$STUB_OUTPUT" # Remove extra directories and files - rm -rf $STUB_OUTPUT/US/letsencrypt - rm -rf $STUB_OUTPUT/EU/letsencrypt - rm $STUB_OUTPUT/US/env/uid.env $STUB_OUTPUT/US/config.yml - rm $STUB_OUTPUT/EU/env/uid.env $STUB_OUTPUT/EU/config.yml + rm -rf "$STUB_OUTPUT/US/letsencrypt" + rm -rf "$STUB_OUTPUT/EU/letsencrypt" + rm "$STUB_OUTPUT/US/env/uid.env" "$STUB_OUTPUT/US/config.yml" + rm "$STUB_OUTPUT/EU/env/uid.env" "$STUB_OUTPUT/EU/config.yml" # Create uid environment files - touch $STUB_OUTPUT/US/env/uid.env - touch $STUB_OUTPUT/EU/env/uid.env + touch "$STUB_OUTPUT/US/env/uid.env" + touch "$STUB_OUTPUT/EU/env/uid.env" # Zip up the Docker stub files - cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../.. - cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../.. + cd docker-stub/US; zip -r ../../docker-stub-US.zip ./*; cd ../.. + cd docker-stub/EU; zip -r ../../docker-stub-EU.zip ./*; cd ../.. - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main @@ -423,6 +427,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 diff --git a/.github/workflows/cleanup-after-pr.yml b/.github/workflows/cleanup-after-pr.yml index e39bf8ea3a..4e59f1fa96 100644 --- a/.github/workflows/cleanup-after-pr.yml +++ b/.github/workflows/cleanup-after-pr.yml @@ -22,7 +22,7 @@ jobs: client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to Azure ACR - run: az acr login -n $_AZ_REGISTRY --only-show-errors + run: az acr login -n "$_AZ_REGISTRY" --only-show-errors ########## Remove Docker images ########## - name: Remove the Docker image from ACR @@ -45,20 +45,20 @@ jobs: - Setup - Sso run: | - for SERVICE in $(echo "${{ env.SERVICES }}" | yq e ".services[]" - ) + for SERVICE in $(echo "${SERVICES}" | yq e ".services[]" - ) do - SERVICE_NAME=$(echo $SERVICE | awk '{print tolower($0)}') + SERVICE_NAME=$(echo "$SERVICE" | awk '{print tolower($0)}') IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG" TAG_EXISTS=$( - az acr repository show-tags --name $_AZ_REGISTRY --repository $SERVICE_NAME \ - | jq --arg $TAG "$IMAGE_TAG" -e '. | any(. == "$TAG")' + az acr repository show-tags --name "$_AZ_REGISTRY" --repository "$SERVICE_NAME" \ + | jq --arg TAG "$IMAGE_TAG" -e '. | any(. == $TAG)' ) if [[ "$TAG_EXISTS" == "true" ]]; then echo "[*] Tag exists. Removing tag" - az acr repository delete --name $_AZ_REGISTRY --image $SERVICE_NAME:$IMAGE_TAG --yes + az acr repository delete --name "$_AZ_REGISTRY" --image "$SERVICE_NAME:$IMAGE_TAG" --yes else echo "[*] Tag does not exist. No action needed" fi diff --git a/.github/workflows/cleanup-rc-branch.yml b/.github/workflows/cleanup-rc-branch.yml index 5c74284423..63079826c7 100644 --- a/.github/workflows/cleanup-rc-branch.yml +++ b/.github/workflows/cleanup-rc-branch.yml @@ -35,6 +35,8 @@ jobs: with: ref: main token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + persist-credentials: false + fetch-depth: 0 - name: Check if a RC branch exists id: branch-check @@ -43,11 +45,11 @@ jobs: rc_branch_check=$(git ls-remote --heads origin rc | wc -l) if [[ "${hotfix_rc_branch_check}" -gt 0 ]]; then - echo "hotfix-rc branch exists." | tee -a $GITHUB_STEP_SUMMARY - echo "name=hotfix-rc" >> $GITHUB_OUTPUT + echo "hotfix-rc branch exists." | tee -a "$GITHUB_STEP_SUMMARY" + echo "name=hotfix-rc" >> "$GITHUB_OUTPUT" elif [[ "${rc_branch_check}" -gt 0 ]]; then - echo "rc branch exists." | tee -a $GITHUB_STEP_SUMMARY - echo "name=rc" >> $GITHUB_OUTPUT + echo "rc branch exists." | tee -a "$GITHUB_STEP_SUMMARY" + echo "name=rc" >> "$GITHUB_OUTPUT" fi - name: Delete RC branch @@ -55,6 +57,6 @@ jobs: BRANCH_NAME: ${{ steps.branch-check.outputs.name }} run: | if ! [[ -z "$BRANCH_NAME" ]]; then - git push --quiet origin --delete $BRANCH_NAME - echo "Deleted $BRANCH_NAME branch." | tee -a $GITHUB_STEP_SUMMARY + git push --quiet origin --delete "$BRANCH_NAME" + echo "Deleted $BRANCH_NAME branch." | tee -a "$GITHUB_STEP_SUMMARY" fi diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index 75e0c43306..35e6cfdd40 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -19,9 +19,9 @@ jobs: id: check-secret-access run: | if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then - echo "available=true" >> $GITHUB_OUTPUT; + echo "available=true" >> "$GITHUB_OUTPUT"; else - echo "available=false" >> $GITHUB_OUTPUT; + echo "available=false" >> "$GITHUB_OUTPUT"; fi refs: @@ -37,6 +37,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -65,14 +67,14 @@ jobs: - name: Add label if: steps.collect.outputs.any-changed == 'true' - run: gh pr edit $PR_NUMBER --add-label feature-flag + run: gh pr edit "$PR_NUMBER" --add-label feature-flag env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} - name: Remove label if: steps.collect.outputs.any-changed == 'false' - run: gh pr edit $PR_NUMBER --remove-label feature-flag + run: gh pr edit "$PR_NUMBER" --remove-label feature-flag env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/enforce-labels.yml b/.github/workflows/enforce-labels.yml index 353127c751..1759b29787 100644 --- a/.github/workflows/enforce-labels.yml +++ b/.github/workflows/enforce-labels.yml @@ -17,5 +17,5 @@ jobs: - name: Check for label run: | echo "PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" - echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> $GITHUB_STEP_SUMMARY + echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> "$GITHUB_STEP_SUMMARY" exit 1 diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index 9bc6da89e7..cdb53109f5 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -63,13 +63,15 @@ jobs: # Datadog agent for collecting OTEL metrics from k6 - name: Start Datadog agent + env: + DD_API_KEY: ${{ steps.get-kv-secrets.outputs.DD-API-KEY }} run: | docker run --detach \ --name datadog-agent \ -p 4317:4317 \ -p 5555:5555 \ -e DD_SITE=us3.datadoghq.com \ - -e DD_API_KEY=${{ steps.get-kv-secrets.outputs.DD-API-KEY }} \ + -e DD_API_KEY="${DD_API_KEY}" \ -e DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 \ -e DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 \ -e DD_HEALTH_PORT=5555 \ diff --git a/.github/workflows/protect-files.yml b/.github/workflows/protect-files.yml index 546b8344a6..a939be6fdb 100644 --- a/.github/workflows/protect-files.yml +++ b/.github/workflows/protect-files.yml @@ -34,6 +34,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 + persist-credentials: false - name: Check for file changes id: check-changes @@ -43,9 +44,9 @@ jobs: for file in $MODIFIED_FILES do if [[ $file == *"${{ matrix.path }}"* ]]; then - echo "changes_detected=true" >> $GITHUB_OUTPUT + echo "changes_detected=true" >> "$GITHUB_OUTPUT" break - else echo "changes_detected=false" >> $GITHUB_OUTPUT + else echo "changes_detected=false" >> "$GITHUB_OUTPUT" fi done diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 444c2289d1..2272387d84 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,21 +36,23 @@ jobs: steps: - name: Version output id: version-output + env: + INPUT_VERSION: ${{ inputs.version }} run: | - if [[ "${{ inputs.version }}" == "latest" || "${{ inputs.version }}" == "" ]]; then + if [[ "${INPUT_VERSION}" == "latest" || "${INPUT_VERSION}" == "" ]]; then VERSION=$(curl "https://api.github.com/repos/bitwarden/server/releases" | jq -c '.[] | select(.tag_name) | .tag_name' | head -1 | grep -ohE '20[0-9]{2}\.([1-9]|1[0-2])\.[0-9]+') echo "Latest Released Version: $VERSION" - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" else - echo "Release Version: ${{ inputs.version }}" - echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + echo "Release Version: ${INPUT_VERSION}" + echo "version=${INPUT_VERSION}" >> "$GITHUB_OUTPUT" fi - name: Get branch name id: branch run: | - BRANCH_NAME=$(basename ${{ github.ref }}) - echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT + BRANCH_NAME=$(basename "${GITHUB_REF}") + echo "branch-name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" - name: Create GitHub deployment uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7 @@ -105,6 +107,9 @@ jobs: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false - name: Set up project name id: setup @@ -112,7 +117,7 @@ jobs: PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}') echo "Matrix name: ${{ matrix.project_name }}" echo "PROJECT_NAME: $PROJECT_NAME" - echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT + echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT" ########## ACR PROD ########## - name: Log in to Azure @@ -123,16 +128,16 @@ jobs: client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to Azure ACR - run: az acr login -n $_AZ_REGISTRY --only-show-errors + run: az acr login -n "$_AZ_REGISTRY" --only-show-errors - name: Pull latest project image env: PROJECT_NAME: ${{ steps.setup.outputs.project_name }} run: | if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then - docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest + docker pull "$_AZ_REGISTRY/$PROJECT_NAME:latest" else - docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME + docker pull "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME" fi - name: Tag version and latest @@ -140,10 +145,10 @@ jobs: PROJECT_NAME: ${{ steps.setup.outputs.project_name }} run: | if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then - docker tag $_AZ_REGISTRY/$PROJECT_NAME:latest $_AZ_REGISTRY/$PROJECT_NAME:dryrun + docker tag "$_AZ_REGISTRY/$PROJECT_NAME:latest" "$_AZ_REGISTRY/$PROJECT_NAME:dryrun" else - docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION - docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:latest + docker tag "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME" "$_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION" + docker tag "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME" "$_AZ_REGISTRY/$PROJECT_NAME:latest" fi - name: Push version and latest image @@ -151,10 +156,10 @@ jobs: PROJECT_NAME: ${{ steps.setup.outputs.project_name }} run: | if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then - docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun + docker push "$_AZ_REGISTRY/$PROJECT_NAME:dryrun" else - docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION - docker push $_AZ_REGISTRY/$PROJECT_NAME:latest + docker push "$_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION" + docker push "$_AZ_REGISTRY/$PROJECT_NAME:latest" fi - name: Log out of Docker diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bb19b4da1..75b4df4e5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,9 @@ jobs: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false - name: Check release version id: version @@ -52,8 +55,8 @@ jobs: - name: Get branch name id: branch run: | - BRANCH_NAME=$(basename ${{ github.ref }}) - echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT + BRANCH_NAME=$(basename "${GITHUB_REF}") + echo "branch-name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" release: name: Create GitHub release diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 67e1d8a926..92452102cf 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -46,7 +46,7 @@ jobs: BRANCH="hotfix-rc" fi - echo "branch=$BRANCH" >> $GITHUB_OUTPUT + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" bump_version: name: Bump Version @@ -95,6 +95,7 @@ jobs: with: ref: main token: ${{ steps.app-token.outputs.token }} + persist-credentials: true - name: Configure Git run: | @@ -110,7 +111,7 @@ jobs: id: current-version run: | CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props) - echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" - name: Verify input version if: ${{ inputs.version_number_override != '' }} @@ -120,16 +121,15 @@ jobs: run: | # Error if version has not changed. if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then - echo "Specified override version is the same as the current version." >> $GITHUB_STEP_SUMMARY + echo "Specified override version is the same as the current version." >> "$GITHUB_STEP_SUMMARY" exit 1 fi # Check if version is newer. - printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V - if [ $? -eq 0 ]; then + if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; then echo "Version is newer than the current version." else - echo "Version is older than the current version." >> $GITHUB_STEP_SUMMARY + echo "Version is older than the current version." >> "$GITHUB_STEP_SUMMARY" exit 1 fi @@ -160,15 +160,20 @@ jobs: id: set-final-version-output env: VERSION: ${{ inputs.version_number_override }} + BUMP_VERSION_OVERRIDE_OUTCOME: ${{ steps.bump-version-override.outcome }} + BUMP_VERSION_AUTOMATIC_OUTCOME: ${{ steps.bump-version-automatic.outcome }} + CALCULATE_NEXT_VERSION: ${{ steps.calculate-next-version.outputs.version }} run: | - if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then - echo "version=$VERSION" >> $GITHUB_OUTPUT - elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then - echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT + if [[ "${BUMP_VERSION_OVERRIDE_OUTCOME}" = "success" ]]; then + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + elif [[ "${BUMP_VERSION_AUTOMATIC_OUTCOME}" = "success" ]]; then + echo "version=${CALCULATE_NEXT_VERSION}" >> "$GITHUB_OUTPUT" fi - name: Commit files - run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a + env: + FINAL_VERSION: ${{ steps.set-final-version-output.outputs.version }} + run: git commit -m "Bumped version to $FINAL_VERSION" -a - name: Push changes run: git push @@ -213,13 +218,15 @@ jobs: with: ref: ${{ inputs.target_ref }} token: ${{ steps.app-token.outputs.token }} + persist-credentials: true + fetch-depth: 0 - 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 + 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 @@ -227,8 +234,8 @@ jobs: env: BRANCH_NAME: ${{ needs.setup.outputs.branch }} run: | - git switch --quiet --create $BRANCH_NAME - git push --quiet --set-upstream origin $BRANCH_NAME + git switch --quiet --create "$BRANCH_NAME" + git push --quiet --set-upstream origin "$BRANCH_NAME" move_edd_db_scripts: name: Move EDD database scripts diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml index 83cbc3bb54..ec7628d16c 100644 --- a/.github/workflows/review-code.yml +++ b/.github/workflows/review-code.yml @@ -26,14 +26,14 @@ jobs: id: check_changes run: | # Ensure we have the base branch - git fetch origin ${{ github.base_ref }} + git fetch origin "${GITHUB_BASE_REF}" - echo "Comparing changes between origin/${{ github.base_ref }} and HEAD" - CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + echo "Comparing changes between origin/${GITHUB_BASE_REF} and HEAD" + CHANGED_FILES=$(git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD") if [ -z "$CHANGED_FILES" ]; then echo "Zero files changed" - echo "vault_team_changes=false" >> $GITHUB_OUTPUT + echo "vault_team_changes=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -42,7 +42,7 @@ jobs: if [ -z "$VAULT_PATTERNS" ]; then echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS" - echo "vault_team_changes=false" >> $GITHUB_OUTPUT + echo "vault_team_changes=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -72,7 +72,7 @@ jobs: fi done - echo "vault_team_changes=$vault_team_changes" >> $GITHUB_OUTPUT + echo "vault_team_changes=$vault_team_changes" >> "$GITHUB_OUTPUT" if [ "$vault_team_changes" = "true" ]; then echo "" diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index cdba344195..4a973c0b7c 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -45,6 +45,8 @@ jobs: steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 @@ -139,26 +141,26 @@ jobs: - name: Print MySQL Logs if: failure() - run: 'docker logs $(docker ps --quiet --filter "name=mysql")' + run: 'docker logs "$(docker ps --quiet --filter "name=mysql")"' - name: Print MariaDB Logs if: failure() - run: 'docker logs $(docker ps --quiet --filter "name=mariadb")' + run: 'docker logs "$(docker ps --quiet --filter "name=mariadb")"' - name: Print Postgres Logs if: failure() - run: 'docker logs $(docker ps --quiet --filter "name=postgres")' + run: 'docker logs "$(docker ps --quiet --filter "name=postgres")"' - name: Print MSSQL Logs if: failure() - run: 'docker logs $(docker ps --quiet --filter "name=mssql")' + run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"' - name: Report test results uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results - path: "**/*-test-results.trx" + path: "./**/*-test-results.trx" reporter: dotnet-trx fail-on-error: true @@ -177,6 +179,8 @@ jobs: steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7783fa14b5..36ab8785d5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 diff --git a/src/Api/Controllers/PlansController.cs b/src/Api/Billing/Controllers/PlansController.cs similarity index 69% rename from src/Api/Controllers/PlansController.cs rename to src/Api/Billing/Controllers/PlansController.cs index 11b070fb66..d43a1e6044 100644 --- a/src/Api/Controllers/PlansController.cs +++ b/src/Api/Billing/Controllers/PlansController.cs @@ -3,7 +3,7 @@ using Bit.Core.Billing.Pricing; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Controllers; +namespace Bit.Api.Billing.Controllers; [Route("plans")] [Authorize("Web")] @@ -18,4 +18,11 @@ public class PlansController( var responses = plans.Select(plan => new PlanResponseModel(plan)); return new ListResponseModel(responses); } + + [HttpGet("premium")] + public async Task GetPremiumPlanAsync() + { + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); + return TypedResults.Ok(premiumPlan); + } } diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index c5fdc3287a..fa01acabda 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -3,6 +3,7 @@ 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.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -50,7 +51,8 @@ public class CreatePremiumCloudHostedSubscriptionCommand( ISubscriberService subscriberService, IUserService userService, IPushNotificationService pushNotificationService, - ILogger logger) + ILogger logger, + IPricingClient pricingClient) : BaseBillingCommand(logger), ICreatePremiumCloudHostedSubscriptionCommand { private static readonly List _expand = ["tax"]; @@ -255,11 +257,13 @@ public class CreatePremiumCloudHostedSubscriptionCommand( Customer customer, int? storage) { + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); + var subscriptionItemOptionsList = new List { new () { - Price = StripeConstants.Prices.PremiumAnnually, + Price = premiumPlan.Seat.StripePriceId, Quantity = 1 } }; @@ -268,7 +272,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { - Price = StripeConstants.Prices.StoragePlanPersonal, + Price = premiumPlan.Storage.StripePriceId, Quantity = storage }); } diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs index 9275bcf3d9..5f09b8b77b 100644 --- a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs @@ -1,14 +1,12 @@ using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Pricing; using Bit.Core.Services; using Microsoft.Extensions.Logging; using Stripe; namespace Bit.Core.Billing.Premium.Commands; -using static StripeConstants; - public interface IPreviewPremiumTaxCommand { Task> Run( @@ -18,6 +16,7 @@ public interface IPreviewPremiumTaxCommand public class PreviewPremiumTaxCommand( ILogger logger, + IPricingClient pricingClient, IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IPreviewPremiumTaxCommand { public Task> Run( @@ -25,6 +24,8 @@ public class PreviewPremiumTaxCommand( BillingAddress billingAddress) => HandleAsync<(decimal, decimal)>(async () => { + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); + var options = new InvoiceCreatePreviewOptions { AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, @@ -41,7 +42,7 @@ public class PreviewPremiumTaxCommand( { Items = [ - new InvoiceSubscriptionDetailsItemOptions { Price = Prices.PremiumAnnually, Quantity = 1 } + new InvoiceSubscriptionDetailsItemOptions { Price = premiumPlan.Seat.StripePriceId, Quantity = 1 } ] } }; @@ -50,7 +51,7 @@ public class PreviewPremiumTaxCommand( { options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions { - Price = Prices.StoragePlanPersonal, + Price = premiumPlan.Storage.StripePriceId, Quantity = additionalStorage }); } diff --git a/src/Core/Billing/Pricing/IPricingClient.cs b/src/Core/Billing/Pricing/IPricingClient.cs index bc3f142dda..18588ae432 100644 --- a/src/Core/Billing/Pricing/IPricingClient.cs +++ b/src/Core/Billing/Pricing/IPricingClient.cs @@ -3,12 +3,14 @@ using Bit.Core.Exceptions; using Bit.Core.Models.StaticStore; using Bit.Core.Utilities; -#nullable enable - namespace Bit.Core.Billing.Pricing; +using OrganizationPlan = Plan; +using PremiumPlan = Premium.Plan; + public interface IPricingClient { + // TODO: Rename with Organization focus. /// /// Retrieve a Bitwarden plan by its . If the feature flag 'use-pricing-service' is enabled, /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing . @@ -16,8 +18,9 @@ public interface IPricingClient /// The type of plan to retrieve. /// A Bitwarden record or null in the case the plan could not be found or the method was executed from a self-hosted instance. /// Thrown when the request to the Pricing Service fails unexpectedly. - Task GetPlan(PlanType planType); + Task GetPlan(PlanType planType); + // TODO: Rename with Organization focus. /// /// Retrieve a Bitwarden plan by its . If the feature flag 'use-pricing-service' is enabled, /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing . @@ -26,13 +29,17 @@ public interface IPricingClient /// A Bitwarden record. /// Thrown when the for the provided could not be found or the method was executed from a self-hosted instance. /// Thrown when the request to the Pricing Service fails unexpectedly. - Task GetPlanOrThrow(PlanType planType); + Task GetPlanOrThrow(PlanType planType); + // TODO: Rename with Organization focus. /// /// Retrieve all the Bitwarden plans. If the feature flag 'use-pricing-service' is enabled, /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing . /// /// A list of Bitwarden records or an empty list in the case the method is executed from a self-hosted instance. /// Thrown when the request to the Pricing Service fails unexpectedly. - Task> ListPlans(); + Task> ListPlans(); + + Task GetAvailablePremiumPlan(); + Task> ListPremiumPlans(); } diff --git a/src/Core/Billing/Pricing/Models/Feature.cs b/src/Core/Billing/Pricing/Organizations/Feature.cs similarity index 69% rename from src/Core/Billing/Pricing/Models/Feature.cs rename to src/Core/Billing/Pricing/Organizations/Feature.cs index ea9da5217d..df10d2bcf8 100644 --- a/src/Core/Billing/Pricing/Models/Feature.cs +++ b/src/Core/Billing/Pricing/Organizations/Feature.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Pricing.Models; +namespace Bit.Core.Billing.Pricing.Organizations; public class Feature { diff --git a/src/Core/Billing/Pricing/Models/Plan.cs b/src/Core/Billing/Pricing/Organizations/Plan.cs similarity index 94% rename from src/Core/Billing/Pricing/Models/Plan.cs rename to src/Core/Billing/Pricing/Organizations/Plan.cs index 5b4296474b..c533c271cb 100644 --- a/src/Core/Billing/Pricing/Models/Plan.cs +++ b/src/Core/Billing/Pricing/Organizations/Plan.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Pricing.Models; +namespace Bit.Core.Billing.Pricing.Organizations; public class Plan { diff --git a/src/Core/Billing/Pricing/PlanAdapter.cs b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs similarity index 98% rename from src/Core/Billing/Pricing/PlanAdapter.cs rename to src/Core/Billing/Pricing/Organizations/PlanAdapter.cs index 560987b891..390f7b2146 100644 --- a/src/Core/Billing/Pricing/PlanAdapter.cs +++ b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs @@ -1,8 +1,6 @@ using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing.Models; -using Plan = Bit.Core.Billing.Pricing.Models.Plan; -namespace Bit.Core.Billing.Pricing; +namespace Bit.Core.Billing.Pricing.Organizations; public record PlanAdapter : Core.Models.StaticStore.Plan { diff --git a/src/Core/Billing/Pricing/Models/Purchasable.cs b/src/Core/Billing/Pricing/Organizations/Purchasable.cs similarity index 99% rename from src/Core/Billing/Pricing/Models/Purchasable.cs rename to src/Core/Billing/Pricing/Organizations/Purchasable.cs index 7cb4ee00c1..f6704394f7 100644 --- a/src/Core/Billing/Pricing/Models/Purchasable.cs +++ b/src/Core/Billing/Pricing/Organizations/Purchasable.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; using OneOf; -namespace Bit.Core.Billing.Pricing.Models; +namespace Bit.Core.Billing.Pricing.Organizations; [JsonConverter(typeof(PurchasableJsonConverter))] public class Purchasable(OneOf input) : OneOfBase(input) diff --git a/src/Core/Billing/Pricing/Premium/Plan.cs b/src/Core/Billing/Pricing/Premium/Plan.cs new file mode 100644 index 0000000000..f377157363 --- /dev/null +++ b/src/Core/Billing/Pricing/Premium/Plan.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Billing.Pricing.Premium; + +public class Plan +{ + public string Name { get; init; } = null!; + public int? LegacyYear { get; init; } + public bool Available { get; init; } + public Purchasable Seat { get; init; } = null!; + public Purchasable Storage { get; init; } = null!; +} diff --git a/src/Core/Billing/Pricing/Premium/Purchasable.cs b/src/Core/Billing/Pricing/Premium/Purchasable.cs new file mode 100644 index 0000000000..633eb2e8aa --- /dev/null +++ b/src/Core/Billing/Pricing/Premium/Purchasable.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Pricing.Premium; + +public class Purchasable +{ + public string StripePriceId { get; init; } = null!; + public decimal Price { get; init; } +} diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs index a3db8ce07f..d2630ea43b 100644 --- a/src/Core/Billing/Pricing/PricingClient.cs +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -1,24 +1,27 @@ using System.Net; using System.Net.Http.Json; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing.Organizations; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.Extensions.Logging; -using Plan = Bit.Core.Models.StaticStore.Plan; - -#nullable enable namespace Bit.Core.Billing.Pricing; +using OrganizationPlan = Bit.Core.Models.StaticStore.Plan; +using PremiumPlan = Premium.Plan; +using Purchasable = Premium.Purchasable; + public class PricingClient( IFeatureService featureService, GlobalSettings globalSettings, HttpClient httpClient, ILogger logger) : IPricingClient { - public async Task GetPlan(PlanType planType) + public async Task GetPlan(PlanType planType) { if (globalSettings.SelfHosted) { @@ -40,16 +43,14 @@ public class PricingClient( return null; } - var response = await httpClient.GetAsync($"plans/lookup/{lookupKey}"); + var response = await httpClient.GetAsync($"plans/organization/{lookupKey}"); if (response.IsSuccessStatusCode) { - var plan = await response.Content.ReadFromJsonAsync(); - if (plan == null) - { - throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); - } - return new PlanAdapter(plan); + var plan = await response.Content.ReadFromJsonAsync(); + return plan == null + ? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null") + : new PlanAdapter(plan); } if (response.StatusCode == HttpStatusCode.NotFound) @@ -62,19 +63,14 @@ public class PricingClient( message: $"Request to the Pricing Service failed with status code {response.StatusCode}"); } - public async Task GetPlanOrThrow(PlanType planType) + public async Task GetPlanOrThrow(PlanType planType) { var plan = await GetPlan(planType); - if (plan == null) - { - throw new NotFoundException(); - } - - return plan; + return plan ?? throw new NotFoundException($"Could not find plan for type {planType}"); } - public async Task> ListPlans() + public async Task> ListPlans() { if (globalSettings.SelfHosted) { @@ -88,16 +84,51 @@ public class PricingClient( return StaticStore.Plans.ToList(); } - var response = await httpClient.GetAsync("plans"); + var response = await httpClient.GetAsync("plans/organization"); if (response.IsSuccessStatusCode) { - var plans = await response.Content.ReadFromJsonAsync>(); - if (plans == null) - { - throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); - } - return plans.Select(Plan (plan) => new PlanAdapter(plan)).ToList(); + var plans = await response.Content.ReadFromJsonAsync>(); + return plans == null + ? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null") + : plans.Select(OrganizationPlan (plan) => new PlanAdapter(plan)).ToList(); + } + + throw new BillingException( + message: $"Request to the Pricing Service failed with status {response.StatusCode}"); + } + + public async Task GetAvailablePremiumPlan() + { + var premiumPlans = await ListPremiumPlans(); + + var availablePlan = premiumPlans.FirstOrDefault(premiumPlan => premiumPlan.Available); + + return availablePlan ?? throw new NotFoundException("Could not find available premium plan"); + } + + public async Task> ListPremiumPlans() + { + if (globalSettings.SelfHosted) + { + return []; + } + + var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService); + var fetchPremiumPriceFromPricingService = + featureService.IsEnabled(FeatureFlagKeys.PM26793_FetchPremiumPriceFromPricingService); + + if (!usePricingService || !fetchPremiumPriceFromPricingService) + { + return [CurrentPremiumPlan]; + } + + var response = await httpClient.GetAsync("plans/premium"); + + if (response.IsSuccessStatusCode) + { + var plans = await response.Content.ReadFromJsonAsync>(); + return plans ?? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); } throw new BillingException( @@ -130,4 +161,13 @@ public class PricingClient( PlanType.TeamsStarter2023 => "teams-starter-2023", _ => null }; + + private static PremiumPlan CurrentPremiumPlan => new() + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal } + }; } diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index e7e67c0a11..3170060de4 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -6,6 +6,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Enums; @@ -30,7 +31,8 @@ public class PremiumUserBillingService( ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, - IUserRepository userRepository) : IPremiumUserBillingService + IUserRepository userRepository, + IPricingClient pricingClient) : IPremiumUserBillingService { public async Task Credit(User user, decimal amount) { @@ -301,11 +303,13 @@ public class PremiumUserBillingService( Customer customer, int? storage) { + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); + var subscriptionItemOptionsList = new List { new () { - Price = StripeConstants.Prices.PremiumAnnually, + Price = premiumPlan.Seat.StripePriceId, Quantity = 1 } }; @@ -314,7 +318,7 @@ public class PremiumUserBillingService( { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { - Price = StripeConstants.Prices.StoragePlanPersonal, + Price = premiumPlan.Storage.StripePriceId, Quantity = storage }); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 68f32f8bda..f7ce3aa59e 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -154,6 +154,7 @@ public static class FeatureFlagKeys public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword = "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; + public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; @@ -185,6 +186,7 @@ public static class FeatureFlagKeys public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button"; public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog"; public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page"; + public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs new file mode 100644 index 0000000000..095cdc82d7 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs @@ -0,0 +1,675 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Verify your email to access this Bitwarden Send +

+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
Your verification code is:
+ +
+ +
{{Token}}
+ +
+ +
+ +
+ +
This code expires in {{Expiry}} minutes. After that, you'll need to + verify your email again.
+ +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + +
+ +

+ Bitwarden Send transmits sensitive, temporary information to + others easily and securely. Learn more about + Bitwarden Send + or + sign up + to try it today. +

+ +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

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

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

+

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

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.text.hbs new file mode 100644 index 0000000000..7c9c1db527 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.text.hbs @@ -0,0 +1,9 @@ +{{#>BasicTextLayout}} +Verify your email to access this Bitwarden Send. + +Your verification code is: {{Token}} + +This code can only be used once and expires in {{Expiry}} minutes. After that you'll need to verify your email again. + +Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about Bitwarden Send or sign up to try it today. +{{/BasicTextLayout}} diff --git a/src/Core/MailTemplates/Mjml/.mjmlconfig b/src/Core/MailTemplates/Mjml/.mjmlconfig index 7560e0fb96..c382f10a12 100644 --- a/src/Core/MailTemplates/Mjml/.mjmlconfig +++ b/src/Core/MailTemplates/Mjml/.mjmlconfig @@ -1,5 +1,5 @@ { "packages": [ - "components/hero" + "components/mj-bw-hero" ] } diff --git a/src/Core/MailTemplates/Mjml/components/footer.mjml b/src/Core/MailTemplates/Mjml/components/footer.mjml index 0634033618..2b2268f33b 100644 --- a/src/Core/MailTemplates/Mjml/components/footer.mjml +++ b/src/Core/MailTemplates/Mjml/components/footer.mjml @@ -2,38 +2,38 @@ @@ -45,8 +45,8 @@

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

diff --git a/src/Core/MailTemplates/Mjml/components/head.mjml b/src/Core/MailTemplates/Mjml/components/head.mjml index 389ae77c12..cf78cd6223 100644 --- a/src/Core/MailTemplates/Mjml/components/head.mjml +++ b/src/Core/MailTemplates/Mjml/components/head.mjml @@ -4,7 +4,7 @@ font-size="16px" /> - + @@ -22,3 +22,9 @@ border-radius: 3px; } + + + +@media only screen and + (max-width: 480px) { .hide-small-img { display: none !important; } .send-bubble { padding-left: 20px; padding-right: 20px; width: 90% !important; } } + diff --git a/src/Core/MailTemplates/Mjml/components/hero.js b/src/Core/MailTemplates/Mjml/components/hero.js deleted file mode 100644 index 6c5bd9bc99..0000000000 --- a/src/Core/MailTemplates/Mjml/components/hero.js +++ /dev/null @@ -1,64 +0,0 @@ -const { BodyComponent } = require("mjml-core"); -class MjBwHero extends BodyComponent { - static dependencies = { - // Tell the validator which tags are allowed as our component's parent - "mj-column": ["mj-bw-hero"], - "mj-wrapper": ["mj-bw-hero"], - // Tell the validator which tags are allowed as our component's children - "mj-bw-hero": [], - }; - - static allowedAttributes = { - "img-src": "string", - title: "string", - "button-text": "string", - "button-url": "string", - }; - - static defaultAttributes = {}; - - render() { - return this.renderMJML(` - - - - -

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

-
- - ${this.getAttribute("button-text")} - -
- - - -
- `); - } -} - -module.exports = MjBwHero; diff --git a/src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml b/src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml new file mode 100644 index 0000000000..9df0614aae --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml @@ -0,0 +1,18 @@ + + + +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center. +
+
+ + + +
diff --git a/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js b/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js new file mode 100644 index 0000000000..d329d4ea38 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js @@ -0,0 +1,100 @@ +const { BodyComponent } = require("mjml-core"); +class MjBwHero extends BodyComponent { + static dependencies = { + // Tell the validator which tags are allowed as our component's parent + "mj-column": ["mj-bw-hero"], + "mj-wrapper": ["mj-bw-hero"], + // Tell the validator which tags are allowed as our component's children + "mj-bw-hero": [], + }; + + static allowedAttributes = { + "img-src": "string", // REQUIRED: Source for the image displayed in the right-hand side of the blue header area + title: "string", // REQUIRED: large text stating primary purpose of the email + "button-text": "string", // OPTIONAL: text to display in the button + "button-url": "string", // OPTIONAL: URL to navigate to when the button is clicked + "sub-title": "string", // OPTIONAL: smaller text providing additional context for the title + }; + + static defaultAttributes = {}; + + render() { + if (this.getAttribute("button-text") && this.getAttribute("button-url")) { + return this.renderMJML(` + + + + +

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

+
+ + ${this.getAttribute("button-text")} + +
+ + + +
+ `); + } else { + return this.renderMJML(` + + + + +

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

+
+
+ + + +
+ `); + } + } +} + +module.exports = MjBwHero; diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml new file mode 100644 index 0000000000..6ccc481ff8 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + Your verification code is: + {{Token}} + + + This code expires in {{Expiry}} minutes. After that, you'll need to + verify your email again. + + + + + + +

+ Bitwarden Send transmits sensitive, temporary information to + others easily and securely. Learn more about + Bitwarden Send + or + sign up + to try it today. +

+
+
+
+
+ + + + + + + + +
+
diff --git a/src/Core/MailTemplates/Mjml/emails/two-factor.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml similarity index 77% rename from src/Core/MailTemplates/Mjml/emails/two-factor.mjml rename to src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml index 5091e208d3..3b63c278fc 100644 --- a/src/Core/MailTemplates/Mjml/emails/two-factor.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml @@ -1,10 +1,10 @@ - + - + - + diff --git a/src/Core/MailTemplates/Mjml/emails/invite.mjml b/src/Core/MailTemplates/Mjml/emails/invite.mjml index 4eae12d0dc..cdace39c95 100644 --- a/src/Core/MailTemplates/Mjml/emails/invite.mjml +++ b/src/Core/MailTemplates/Mjml/emails/invite.mjml @@ -22,26 +22,7 @@
- - - -

- We’re here for you! -

- If you have any questions, search the Bitwarden - Help - site or - contact us. -
-
- - - -
+ diff --git a/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs b/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs index 5faf550e60..5eabd5ba2c 100644 --- a/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs +++ b/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs @@ -9,4 +9,5 @@ public class DefaultEmailOtpViewModel : BaseMailModel public string? TheDate { get; set; } public string? TheTime { get; set; } public string? TimeZone { get; set; } + public string? Expiry { get; set; } } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs index 739dca5228..d4e1b3cd8d 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs @@ -226,7 +226,11 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs // Check minimum seats currently in use by the organization if (organization.SmSeats.Value > update.SmSeats.Value) { + // Retrieve the number of currently occupied Secrets Manager seats for the organization. var occupiedSeats = await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id); + + // Check if the occupied number of seats exceeds the updated seat count. + // If so, throw an exception indicating that the subscription cannot be decreased below the current usage. if (occupiedSeats > update.SmSeats.Value) { throw new BadRequestException($"{occupiedSeats} users are currently occupying Secrets Manager seats. " + @@ -412,7 +416,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs } /// - /// Requests the number of Secret Manager seats and service accounts are currently used by the organization + /// Requests the number of Secret Manager seats and service accounts currently used by the organization /// /// The id of the organization /// A tuple containing the occupied seats and the occupied service account counts diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 6e61c4f8dd..5a3428c25a 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; @@ -31,6 +32,16 @@ public interface IMailService Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose); Task SendSendEmailOtpEmailAsync(string email, string token, string subject); + /// + /// has a default expiry of 5 minutes so we set the expiry to that value int he view model. + /// Sends OTP code token to the specified email address. + /// will replace when MJML templates are fully accepted. + /// + /// Email address to send the OTP to + /// Otp code token + /// subject line of the email + /// Task + Task SendSendEmailOtpEmailv2Async(string email, string token, string subject); Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip); Task SendNoMasterPasswordHintEmailAsync(string email); Task SendMasterPasswordHintEmailAsync(string email, string hint); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 75e0c78702..19705766ed 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -224,6 +224,27 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendSendEmailOtpEmailv2Async(string email, string token, string subject) + { + var message = CreateDefaultMessage(subject, email); + var requestDateTime = DateTime.UtcNow; + var model = new DefaultEmailOtpViewModel + { + Token = token, + Expiry = "5", // This should be configured through the OTPDefaultTokenProviderOptions but for now we will hardcode it to 5 minutes. + TheDate = requestDateTime.ToLongDateString(), + TheTime = requestDateTime.ToShortTimeString(), + TimeZone = _utcTimeZoneDisplay, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + }; + await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmailv2", model); + message.MetaData.Add("SendGridBypassListManagement", true); + // TODO - PM-25380 change to string constant + message.Category = "SendEmailOtp"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip) { // Check if we've sent this email within the last hour diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index bb53933d02..2707401134 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Responses; @@ -896,11 +897,14 @@ public class StripePaymentService : IPaymentService } } + [Obsolete($"Use {nameof(PreviewPremiumTaxCommand)} instead.")] public async Task PreviewInvoiceAsync( PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId) { + var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); + var options = new InvoiceCreatePreviewOptions { AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true, }, @@ -909,8 +913,17 @@ public class StripePaymentService : IPaymentService { Items = [ - new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = StripeConstants.Prices.PremiumAnnually }, - new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.AdditionalStorage, Plan = StripeConstants.Prices.StoragePlanPersonal } + new InvoiceSubscriptionDetailsItemOptions + { + Quantity = 1, + Plan = premiumPlan.Seat.StripePriceId + }, + + new InvoiceSubscriptionDetailsItemOptions + { + Quantity = parameters.PasswordManager.AdditionalStorage, + Plan = premiumPlan.Storage.StripePriceId + } ] }, CustomerDetails = new InvoiceCustomerDetailsOptions @@ -1028,7 +1041,7 @@ public class StripePaymentService : IPaymentService { Items = [ - new() + new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.AdditionalStorage, Plan = plan.PasswordManager.StripeStoragePlanId @@ -1049,7 +1062,7 @@ public class StripePaymentService : IPaymentService { var sponsoredPlan = Utilities.StaticStore.GetSponsoredPlan(parameters.PasswordManager.SponsoredPlan.Value); options.SubscriptionDetails.Items.Add( - new() { Quantity = 1, Plan = sponsoredPlan.StripePlanId } + new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = sponsoredPlan.StripePlanId } ); } else @@ -1057,13 +1070,13 @@ public class StripePaymentService : IPaymentService if (plan.PasswordManager.HasAdditionalSeatsOption) { options.SubscriptionDetails.Items.Add( - new() { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId } + new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId } ); } else { options.SubscriptionDetails.Items.Add( - new() { Quantity = 1, Plan = plan.PasswordManager.StripePlanId } + new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = plan.PasswordManager.StripePlanId } ); } @@ -1071,7 +1084,7 @@ public class StripePaymentService : IPaymentService { if (plan.SecretsManager.HasAdditionalSeatsOption) { - options.SubscriptionDetails.Items.Add(new() + options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.SecretsManager?.Seats ?? 0, Plan = plan.SecretsManager.StripeSeatPlanId @@ -1080,7 +1093,7 @@ public class StripePaymentService : IPaymentService if (plan.SecretsManager.HasAdditionalServiceAccountOption) { - options.SubscriptionDetails.Items.Add(new() + options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, Plan = plan.SecretsManager.StripeServiceAccountPlanId diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index a36b9e37cc..daf1b2078d 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -14,10 +14,10 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; -using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; @@ -72,6 +72,7 @@ public class UserService : UserManager, IUserService private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IDistributedCache _distributedCache; private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly IPricingClient _pricingClient; public UserService( IUserRepository userRepository, @@ -106,7 +107,8 @@ public class UserService : UserManager, IUserService IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IDistributedCache distributedCache, - IPolicyRequirementQuery policyRequirementQuery) + IPolicyRequirementQuery policyRequirementQuery, + IPricingClient pricingClient) : base( store, optionsAccessor, @@ -146,6 +148,7 @@ public class UserService : UserManager, IUserService _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _distributedCache = distributedCache; _policyRequirementQuery = policyRequirementQuery; + _pricingClient = pricingClient; } public Guid? GetProperUserId(ClaimsPrincipal principal) @@ -972,8 +975,9 @@ public class UserService : UserManager, IUserService throw new BadRequestException("Not a premium user."); } - var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, - StripeConstants.Prices.StoragePlanPersonal); + var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); + + var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, premiumPlan.Storage.StripePriceId); await SaveUserAsync(user); return secret; } diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 7ec05bb1f9..1459fab966 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -98,6 +98,11 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendSendEmailOtpEmailv2Async(string email, string token, string subject) + { + return Task.FromResult(0); + } + public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip) { return Task.FromResult(0); diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs index ca48c4fbec..34a7a6f6e7 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Bit.Core; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Services; @@ -10,6 +11,7 @@ using Duende.IdentityServer.Validation; namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; public class SendEmailOtpRequestValidator( + IFeatureService featureService, IOtpTokenProvider otpTokenProvider, IMailService mailService) : ISendAuthenticationMethodValidator { @@ -60,11 +62,20 @@ public class SendEmailOtpRequestValidator( { return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed); } - - await mailService.SendSendEmailOtpEmailAsync( - email, - token, - string.Format(SendAccessConstants.OtpEmail.Subject, token)); + if (featureService.IsEnabled(FeatureFlagKeys.MJMLBasedEmailTemplates)) + { + await mailService.SendSendEmailOtpEmailv2Async( + email, + token, + string.Format(SendAccessConstants.OtpEmail.Subject, token)); + } + else + { + await mailService.SendSendEmailOtpEmailAsync( + email, + token, + string.Format(SendAccessConstants.OtpEmail.Subject, token)); + } return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent); } diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index 8504d3122a..b6d497b7de 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -1,7 +1,9 @@ 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.Premium.Commands; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Platform.Push; @@ -14,6 +16,8 @@ using NSubstitute; using Stripe; using Xunit; using Address = Stripe.Address; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; +using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable; using StripeCustomer = Stripe.Customer; using StripeSubscription = Stripe.Subscription; @@ -28,6 +32,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests private readonly ISubscriberService _subscriberService = Substitute.For(); private readonly IUserService _userService = Substitute.For(); private readonly IPushNotificationService _pushNotificationService = Substitute.For(); + private readonly IPricingClient _pricingClient = Substitute.For(); private readonly CreatePremiumCloudHostedSubscriptionCommand _command; public CreatePremiumCloudHostedSubscriptionCommandTests() @@ -36,6 +41,17 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests baseServiceUri.CloudRegion.Returns("US"); _globalSettings.BaseServiceUri.Returns(baseServiceUri); + // Setup default premium plan with standard pricing + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually }, + Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal } + }; + _pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan); + _command = new CreatePremiumCloudHostedSubscriptionCommand( _braintreeGateway, _globalSettings, @@ -44,7 +60,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests _subscriberService, _userService, _pushNotificationService, - Substitute.For>()); + Substitute.For>(), + _pricingClient); } [Theory, BitAutoData] diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs index 9e919a83f9..d0b2eb7aa4 100644 --- a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs @@ -1,23 +1,38 @@ using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Pricing; using Bit.Core.Services; using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; using static Bit.Core.Billing.Constants.StripeConstants; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; +using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable; namespace Bit.Core.Test.Billing.Premium.Commands; public class PreviewPremiumTaxCommandTests { private readonly ILogger _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly PreviewPremiumTaxCommand _command; public PreviewPremiumTaxCommandTests() { - _command = new PreviewPremiumTaxCommand(_logger, _stripeAdapter); + // Setup default premium plan with standard pricing + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new PremiumPurchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new PremiumPurchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + _pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan); + + _command = new PreviewPremiumTaxCommand(_logger, _pricingClient, _stripeAdapter); } [Fact] diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs index 8b00741215..1e764de6d7 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs @@ -278,21 +278,27 @@ public class UpdateSecretsManagerSubscriptionCommandTests SutProvider sutProvider) { // Arrange - const int seatCount = 10; - var existingSeatCount = 9; - // Make sure Password Manager seats is greater or equal to Secrets Manager seats - organization.Seats = seatCount; + const int initialSeatCount = 9; + const int maxSeatCount = 20; + // This represents the total number of users allowed in the organization. + organization.Seats = maxSeatCount; + // This represents the number of Secrets Manager users allowed in the organization. + organization.SmSeats = initialSeatCount; + // This represents the upper limit of Secrets Manager seats that can be automatically scaled. + organization.MaxAutoscaleSmSeats = maxSeatCount; + + organization.PlanType = PlanType.EnterpriseAnnually; var plan = StaticStore.GetPlan(organization.PlanType); var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { - SmSeats = seatCount, - MaxAutoscaleSmSeats = seatCount + SmSeats = 8, + MaxAutoscaleSmSeats = maxSeatCount }; sutProvider.GetDependency() .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) - .Returns(existingSeatCount); + .Returns(5); // Act await sutProvider.Sut.UpdateSubscriptionAsync(update); @@ -316,21 +322,29 @@ public class UpdateSecretsManagerSubscriptionCommandTests SutProvider sutProvider) { // Arrange - const int seatCount = 10; - const int existingSeatCount = 10; - var ownerDetailsList = new List { new() { Email = "owner@example.com" } }; + const int initialSeatCount = 5; + const int maxSeatCount = 10; - // The amount of seats for users in an organization + // This represents the total number of users allowed in the organization. + organization.Seats = maxSeatCount; + // This represents the number of Secrets Manager users allowed in the organization. + organization.SmSeats = initialSeatCount; + // This represents the upper limit of Secrets Manager seats that can be automatically scaled. + organization.MaxAutoscaleSmSeats = maxSeatCount; + + var ownerDetailsList = new List { new() { Email = "owner@example.com" } }; + organization.PlanType = PlanType.EnterpriseAnnually; var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { - SmSeats = seatCount, - MaxAutoscaleSmSeats = seatCount + SmSeats = maxSeatCount, + MaxAutoscaleSmSeats = maxSeatCount }; sutProvider.GetDependency() .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) - .Returns(existingSeatCount); + .Returns(maxSeatCount); sutProvider.GetDependency() .GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner) .Returns(ownerDetailsList); @@ -340,15 +354,14 @@ public class UpdateSecretsManagerSubscriptionCommandTests // Assert - // Currently being called once each for different validation methods await sutProvider.GetDependency() - .Received(2) + .Received(1) .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id); await sutProvider.GetDependency() .Received(1) .SendSecretsManagerMaxSeatLimitReachedEmailAsync(Arg.Is(organization), - Arg.Is(seatCount), + Arg.Is(maxSeatCount), Arg.Is>(emails => emails.Contains(ownerDetailsList[0].Email))); } diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs index 46f61cb333..7fdfacf428 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs @@ -265,9 +265,10 @@ public class SendEmailOtpRequestValidatorTests // Arrange var otpTokenProvider = Substitute.For>(); var mailService = Substitute.For(); + var featureService = Substitute.For(); // Act - var validator = new SendEmailOtpRequestValidator(otpTokenProvider, mailService); + var validator = new SendEmailOtpRequestValidator(featureService, otpTokenProvider, mailService); // Assert Assert.NotNull(validator);