1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-26 12:55:17 +01:00

Merge branch 'main' into PM-11162-assign-to-collection-perm-update

This commit is contained in:
jng 2024-11-06 14:07:07 -05:00
commit 2b55485f58
No known key found for this signature in database
GPG Key ID: AF822623CAD19C85
112 changed files with 19091 additions and 629 deletions

View File

@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"swashbuckle.aspnetcore.cli": { "swashbuckle.aspnetcore.cli": {
"version": "6.8.1", "version": "6.9.0",
"commands": ["swagger"] "commands": ["swagger"]
}, },
"dotnet-ef": { "dotnet-ef": {

View File

@ -29,7 +29,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Check out branch - name: Check out branch
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
@ -53,7 +53,7 @@ jobs:
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }} if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
@ -107,7 +107,7 @@ jobs:
devops-alerts-slack-webhook-url" devops-alerts-slack-webhook-url"
- name: Import GPG keys - name: Import GPG keys
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0 uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0
with: with:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }} gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }} passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}

View File

@ -18,10 +18,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Verify format - name: Verify format
run: dotnet format --verify-no-changes run: dotnet format --verify-no-changes
@ -67,13 +67,13 @@ jobs:
node: true node: true
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Set up Node - name: Set up Node
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with: with:
cache: "npm" cache: "npm"
cache-dependency-path: "**/package-lock.json" cache-dependency-path: "**/package-lock.json"
@ -172,7 +172,7 @@ jobs:
dotnet: true dotnet: true
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check branch to publish - name: Check branch to publish
env: env:
@ -274,14 +274,14 @@ jobs:
- name: Scan Docker image - name: Scan Docker image
id: container-scan id: container-scan
uses: anchore/scan-action@49e50b215b647c5ec97abb66f69af73c46a4ca08 # v5.0.1 uses: anchore/scan-action@5ed195cc06065322983cae4bb31e2a751feb86fd # v5.2.0
with: with:
image: ${{ steps.image-tags.outputs.primary_tag }} image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false fail-build: false
output-format: sarif output-format: sarif
- name: Upload Grype results to GitHub - name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13 uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
with: with:
sarif_file: ${{ steps.container-scan.outputs.sarif }} sarif_file: ${{ steps.container-scan.outputs.sarif }}
@ -291,10 +291,10 @@ jobs:
needs: build-docker needs: build-docker
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Log in to Azure - production subscription - name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -466,10 +466,10 @@ jobs:
- win-x64 - win-x64
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Print environment - name: Print environment
run: | run: |

View File

@ -12,7 +12,7 @@ jobs:
config-exists: ${{ steps.validate-config.outputs.config-exists }} config-exists: ${{ steps.validate-config.outputs.config-exists }}
steps: steps:
- name: Checkout PR - name: Checkout PR
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate config exists in path - name: Validate config exists in path
id: validate-config id: validate-config

View File

@ -23,7 +23,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Checkout main - name: Checkout main
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}

View File

@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Collect - name: Collect
id: collect id: collect

View File

@ -28,7 +28,7 @@ jobs:
label: "DB-migrations-changed" label: "DB-migrations-changed"
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 2 fetch-depth: 2

View File

@ -98,7 +98,7 @@ jobs:
echo "Github Release Option: $RELEASE_OPTION" echo "Github Release Option: $RELEASE_OPTION"
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up project name - name: Set up project name
id: setup id: setup

View File

@ -36,7 +36,7 @@ jobs:
fi fi
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check release version - name: Check release version
id: version id: version

View File

@ -3,12 +3,13 @@ name: Repository management
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
branch_to_cut: task:
default: "rc" default: "Version Bump"
description: "Branch to cut" description: "Task to execute"
options: options:
- "rc" - "Version Bump"
- "hotfix-rc" - "Version Bump and Cut rc"
- "Version Bump and Cut hotfix-rc"
required: true required: true
type: choice type: choice
target_ref: target_ref:
@ -22,18 +23,51 @@ on:
type: string type: string
jobs: jobs:
setup:
name: Setup
runs-on: ubuntu-24.04
outputs:
branch: ${{ steps.set-branch.outputs.branch }}
token: ${{ steps.app-token.outputs.token }}
steps:
- name: Set branch
id: set-branch
env:
TASK: ${{ inputs.task }}
run: |
if [[ "$TASK" == "Version Bump" ]]; then
BRANCH="none"
elif [[ "$TASK" == "Version Bump and Cut rc" ]]; then
BRANCH="rc"
elif [[ "$TASK" == "Version Bump and Cut hotfix-rc" ]]; then
BRANCH="hotfix-rc"
fi
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
- name: Generate GH App token
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
cut_branch: cut_branch:
name: Cut branch name: Cut branch
runs-on: ubuntu-22.04 if: ${{ needs.setup.outputs.branch != 'none' }}
needs: setup
runs-on: ubuntu-24.04
steps: steps:
- name: Check out target ref - name: Check out target ref
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ inputs.target_ref }} ref: ${{ inputs.target_ref }}
token: ${{ needs.setup.outputs.token }}
- name: Check if ${{ inputs.branch_to_cut }} branch exists - name: Check if ${{ needs.setup.outputs.branch }} branch exists
env: env:
BRANCH_NAME: ${{ inputs.branch_to_cut }} BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: | run: |
if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
@ -42,7 +76,7 @@ jobs:
- name: Cut branch - name: Cut branch
env: env:
BRANCH_NAME: ${{ inputs.branch_to_cut }} BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: | run: |
git switch --quiet --create $BRANCH_NAME git switch --quiet --create $BRANCH_NAME
git push --quiet --set-upstream origin $BRANCH_NAME git push --quiet --set-upstream origin $BRANCH_NAME
@ -50,8 +84,11 @@ jobs:
bump_version: bump_version:
name: Bump Version name: Bump Version
runs-on: ubuntu-22.04 if: ${{ always() }}
needs: cut_branch runs-on: ubuntu-24.04
needs:
- cut_branch
- setup
outputs: outputs:
version: ${{ steps.set-final-version-output.outputs.version }} version: ${{ steps.set-final-version-output.outputs.version }}
steps: steps:
@ -62,9 +99,15 @@ jobs:
version: ${{ inputs.version_number_override }} version: ${{ inputs.version_number_override }}
- name: Check out branch - name: Check out branch
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
token: ${{ needs.setup.outputs.token }}
- name: Configure Git
run: |
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
- name: Install xmllint - name: Install xmllint
run: | run: |
@ -123,133 +166,69 @@ jobs:
- name: Set final version output - name: Set final version output
id: set-final-version-output id: set-final-version-output
env:
VERSION: ${{ inputs.version_number_override }}
run: | run: |
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then
echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
fi fi
- name: Configure Git
run: |
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
- name: Create version branch
id: create-branch
run: |
NAME=version_bump_${{ github.ref_name }}_$(date +"%Y-%m-%d")
git switch -c $NAME
echo "name=$NAME" >> $GITHUB_OUTPUT
- name: Commit files - name: Commit files
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
- name: Push changes - name: Push changes
run: git push run: git push
- name: Generate GH App token
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
owner: ${{ github.repository_owner }}
- name: Create version PR
id: create-pr
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_BRANCH: ${{ steps.create-branch.outputs.name }}
TITLE: "Bump version to ${{ steps.set-final-version-output.outputs.version }}"
run: |
PR_URL=$(gh pr create --title "$TITLE" \
--base "main" \
--head "$PR_BRANCH" \
--label "version update" \
--label "automated pr" \
--body "
## Type of change
- [ ] Bug fix
- [ ] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps)
- [X] Other
## Objective
Automated version bump to ${{ steps.set-final-version-output.outputs.version }}")
echo "pr_number=${PR_URL##*/}" >> $GITHUB_OUTPUT
- name: Approve PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
run: gh pr review $PR_NUMBER --approve
- name: Merge PR
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
run: gh pr merge $PR_NUMBER --squash --auto --delete-branch
cherry_pick: cherry_pick:
name: Cherry-Pick Commit(s) name: Cherry-Pick Commit(s)
runs-on: ubuntu-22.04 if: ${{ needs.setup.outputs.branch != 'none' }}
needs: bump_version runs-on: ubuntu-24.04
needs:
- bump_version
- setup
steps: steps:
- name: Check out main branch - name: Check out main branch
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
token: ${{ needs.setup.outputs.token }}
- name: Install xmllint
run: |
sudo apt-get update
sudo apt-get install -y libxml2-utils
- name: Verify version has been updated
env:
NEW_VERSION: ${{ needs.bump_version.outputs.version }}
run: |
# Wait for version to change.
while : ; do
echo "Waiting for version to be updated..."
git pull --force
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
# If the versions don't match we continue the loop, otherwise we break out of the loop.
[[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] || break
sleep 10
done
- name: Get last version commit(s)
id: get-commits
run: |
git switch main
MAIN_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
echo "main_commit=$MAIN_COMMIT" >> $GITHUB_OUTPUT
if [[ $(git ls-remote --heads origin rc) ]]; then
git switch rc
RC_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
echo "rc_commit=$RC_COMMIT" >> $GITHUB_OUTPUT
RC_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
echo "rc_version=$RC_VERSION" >> $GITHUB_OUTPUT
fi
- name: Configure Git - name: Configure Git
run: | run: |
git config --local user.email "actions@github.com" git config --local user.email "actions@github.com"
git config --local user.name "Github Actions" 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: Perform cherry-pick(s)
env: env:
CUT_BRANCH: ${{ inputs.branch_to_cut }} CUT_BRANCH: ${{ needs.setup.outputs.branch }}
MAIN_COMMIT: ${{ steps.get-commits.outputs.main_commit }}
RC_COMMIT: ${{ steps.get-commits.outputs.rc_commit }}
RC_VERSION: ${{ steps.get-commits.outputs.rc_version }}
run: | 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 we are cutting 'hotfix-rc':
if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then
@ -257,25 +236,16 @@ jobs:
if [[ $(git ls-remote --heads origin rc) ]]; then if [[ $(git ls-remote --heads origin rc) ]]; then
# Chery-pick from 'rc' into 'hotfix-rc' # Chery-pick from 'rc' into 'hotfix-rc'
git switch hotfix-rc cherry_pick rc hotfix-rc
HOTFIX_RC_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
if [[ "$HOTFIX_RC_VERSION" != "$RC_VERSION" ]]; then
git cherry-pick --strategy-option=theirs -x $RC_COMMIT
git push -u origin hotfix-rc
fi
# Cherry-pick from 'main' into 'rc' # Cherry-pick from 'main' into 'rc'
git switch rc cherry_pick main rc
git cherry-pick --strategy-option=theirs -x $MAIN_COMMIT
git push -u origin rc
# If the 'rc' branch does not exist: # If the 'rc' branch does not exist:
else else
# Cherry-pick from 'main' into 'hotfix-rc' # Cherry-pick from 'main' into 'hotfix-rc'
git switch hotfix-rc cherry_pick main hotfix-rc
git cherry-pick --strategy-option=theirs -x $MAIN_COMMIT
git push -u origin hotfix-rc
fi fi
@ -283,9 +253,7 @@ jobs:
elif [[ "$CUT_BRANCH" == "rc" ]]; then elif [[ "$CUT_BRANCH" == "rc" ]]; then
# Cherry-pick from 'main' into 'rc' # Cherry-pick from 'main' into 'rc'
git switch rc cherry_pick main rc
git cherry-pick --strategy-option=theirs -x $MAIN_COMMIT
git push -u origin rc
fi fi

View File

@ -26,7 +26,7 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }} --output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub - name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13 uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
with: with:
sarif_file: cx_result.sarif sarif_file: cx_result.sarif
@ -60,19 +60,19 @@ jobs:
steps: steps:
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.4.0 uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with: with:
java-version: 17 java-version: 17
distribution: "zulu" distribution: "zulu"
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Install SonarCloud scanner - name: Install SonarCloud scanner
run: dotnet tool install dotnet-sonarscanner -g run: dotnet tool install dotnet-sonarscanner -g

View File

@ -30,15 +30,34 @@ on:
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests - "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
jobs: jobs:
check-test-secrets:
name: Check for test secrets
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-test-secrets.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-test-secrets
run: |
if [ "${{ secrets.CODECOV_TOKEN }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
fi
test: test:
name: Run tests name: Run tests
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: check-test-secrets
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Restore tools - name: Restore tools
run: dotnet tool restore run: dotnet tool restore
@ -128,7 +147,7 @@ jobs:
- name: Report test results - name: Report test results
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1 uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
if: always() if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }}
with: with:
name: Test Results name: Test Results
path: "**/*-test-results.trx" path: "**/*-test-results.trx"
@ -146,10 +165,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Print environment - name: Print environment
run: | run: |

View File

@ -46,10 +46,10 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Print environment - name: Print environment
run: | run: |

View File

@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2024.10.1</Version> <Version>2024.11.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@ -392,7 +392,9 @@ public class ProviderService : IProviderService
var organization = await _organizationRepository.GetByIdAsync(organizationId); var organization = await _organizationRepository.GetByIdAsync(organizationId);
ThrowOnInvalidPlanType(organization.PlanType); var provider = await _providerRepository.GetByIdAsync(providerId);
ThrowOnInvalidPlanType(provider.Type, organization.PlanType);
if (organization.UseSecretsManager) if (organization.UseSecretsManager)
{ {
@ -407,8 +409,6 @@ public class ProviderService : IProviderService
Key = key, Key = key,
}; };
var provider = await _providerRepository.GetByIdAsync(providerId);
await ApplyProviderPriceRateAsync(organization, provider); await ApplyProviderPriceRateAsync(organization, provider);
await _providerOrganizationRepository.CreateAsync(providerOrganization); await _providerOrganizationRepository.CreateAsync(providerOrganization);
@ -547,7 +547,7 @@ public class ProviderService : IProviderService
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && provider.IsBillable(); var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && provider.IsBillable();
ThrowOnInvalidPlanType(organizationSignup.Plan, consolidatedBillingEnabled); ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan, consolidatedBillingEnabled);
var (organization, _, defaultCollection) = consolidatedBillingEnabled var (organization, _, defaultCollection) = consolidatedBillingEnabled
? await _organizationService.SignupClientAsync(organizationSignup) ? await _organizationService.SignupClientAsync(organizationSignup)
@ -687,11 +687,27 @@ public class ProviderService : IProviderService
return confirmedOwnersIds.Except(providerUserIds).Any(); return confirmedOwnersIds.Except(providerUserIds).Any();
} }
private void ThrowOnInvalidPlanType(PlanType requestedType, bool consolidatedBillingEnabled = false) private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType, bool consolidatedBillingEnabled = false)
{ {
if (consolidatedBillingEnabled && requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly)) if (consolidatedBillingEnabled)
{ {
throw new BadRequestException($"Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed."); switch (providerType)
{
case ProviderType.Msp:
if (requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly))
{
throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
}
break;
case ProviderType.MultiOrganizationEnterprise:
if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually))
{
throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
}
break;
default:
throw new BadRequestException($"Unsupported provider type {providerType}.");
}
} }
if (ProviderDisallowedOrganizationTypes.Contains(requestedType)) if (ProviderDisallowedOrganizationTypes.Contains(requestedType))

View File

@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -208,16 +209,9 @@ public class ProviderBillingService(
{ {
ArgumentNullException.ThrowIfNull(provider); ArgumentNullException.ThrowIfNull(provider);
if (provider.Type != ProviderType.Msp) if (!provider.SupportsConsolidatedBilling())
{ {
logger.LogError("Non-MSP provider ({ProviderID}) cannot scale their seats", provider.Id); logger.LogError("Provider ({ProviderID}) cannot scale their seats", provider.Id);
throw new BillingException();
}
if (!planType.SupportsConsolidatedBilling())
{
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} as it does not support consolidated billing", provider.Id, planType.ToString());
throw new BillingException(); throw new BillingException();
} }
@ -437,144 +431,159 @@ public class ProviderBillingService(
} }
} }
public async Task UpdateSeatMinimums( public async Task ChangePlan(ChangeProviderPlanCommand command)
Provider provider,
int enterpriseSeatMinimum,
int teamsSeatMinimum)
{ {
ArgumentNullException.ThrowIfNull(provider); var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
if (enterpriseSeatMinimum < 0 || teamsSeatMinimum < 0) if (plan == null)
{
throw new BadRequestException("Provider plan not found.");
}
if (plan.PlanType == command.NewPlan)
{
return;
}
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
plan.PlanType = command.NewPlan;
await providerPlanRepository.ReplaceAsync(plan);
Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
var updateOptions = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
{
Id = oldSubscriptionItem.Id,
Deleted = true
}
]
};
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan
// 2. Assign PlanType & PlanName to Organization
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
foreach (var providerOrganization in providerOrganizations)
{
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (organization == null)
{
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = command.NewPlan;
organization.Plan = StaticStore.GetPlan(command.NewPlan).Name;
await organizationRepository.ReplaceAsync(organization);
}
}
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
{
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
{ {
throw new BadRequestException("Provider seat minimums must be at least 0."); throw new BadRequestException("Provider seat minimums must be at least 0.");
} }
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId); Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, command.Id);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>(); var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var providerPlans = await providerPlanRepository.GetByProviderId(command.Id);
var enterpriseProviderPlan = foreach (var newPlanConfiguration in command.Configuration)
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan.SeatMinimum != enterpriseSeatMinimum)
{ {
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager var providerPlan =
providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan);
if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
{
var priceId = StaticStore.GetPlan(newPlanConfiguration.Plan).PasswordManager
.StripeProviderPortalSeatPlanId; .StripeProviderPortalSeatPlanId;
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
var enterpriseSubscriptionItem = subscription.Items.First(item => item.Price.Id == enterprisePriceId); if (providerPlan.PurchasedSeats == 0)
if (enterpriseProviderPlan.PurchasedSeats == 0)
{ {
if (enterpriseProviderPlan.AllocatedSeats > enterpriseSeatMinimum) if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum)
{ {
enterpriseProviderPlan.PurchasedSeats = providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum;
enterpriseProviderPlan.AllocatedSeats - enterpriseSeatMinimum;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Id = enterpriseSubscriptionItem.Id, Id = subscriptionItem.Id,
Price = enterprisePriceId, Price = priceId,
Quantity = enterpriseProviderPlan.AllocatedSeats Quantity = providerPlan.AllocatedSeats
}); });
} }
else else
{ {
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Id = enterpriseSubscriptionItem.Id, Id = subscriptionItem.Id,
Price = enterprisePriceId, Price = priceId,
Quantity = enterpriseSeatMinimum Quantity = newPlanConfiguration.SeatsMinimum
}); });
} }
} }
else else
{ {
var totalEnterpriseSeats = enterpriseProviderPlan.SeatMinimum + enterpriseProviderPlan.PurchasedSeats; var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
if (enterpriseSeatMinimum <= totalEnterpriseSeats) if (newPlanConfiguration.SeatsMinimum <= totalSeats)
{ {
enterpriseProviderPlan.PurchasedSeats = totalEnterpriseSeats - enterpriseSeatMinimum; providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum;
} }
else else
{ {
enterpriseProviderPlan.PurchasedSeats = 0; providerPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Id = enterpriseSubscriptionItem.Id, Id = subscriptionItem.Id,
Price = enterprisePriceId, Price = priceId,
Quantity = enterpriseSeatMinimum Quantity = newPlanConfiguration.SeatsMinimum
}); });
} }
} }
enterpriseProviderPlan.SeatMinimum = enterpriseSeatMinimum; providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum;
await providerPlanRepository.ReplaceAsync(enterpriseProviderPlan); await providerPlanRepository.ReplaceAsync(providerPlan);
} }
var teamsProviderPlan =
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan.SeatMinimum != teamsSeatMinimum)
{
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager
.StripeProviderPortalSeatPlanId;
var teamsSubscriptionItem = subscription.Items.First(item => item.Price.Id == teamsPriceId);
if (teamsProviderPlan.PurchasedSeats == 0)
{
if (teamsProviderPlan.AllocatedSeats > teamsSeatMinimum)
{
teamsProviderPlan.PurchasedSeats = teamsProviderPlan.AllocatedSeats - teamsSeatMinimum;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsProviderPlan.AllocatedSeats
});
}
else
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsSeatMinimum
});
}
}
else
{
var totalTeamsSeats = teamsProviderPlan.SeatMinimum + teamsProviderPlan.PurchasedSeats;
if (teamsSeatMinimum <= totalTeamsSeats)
{
teamsProviderPlan.PurchasedSeats = totalTeamsSeats - teamsSeatMinimum;
}
else
{
teamsProviderPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsSeatMinimum
});
}
}
teamsProviderPlan.SeatMinimum = teamsSeatMinimum;
await providerPlanRepository.ReplaceAsync(teamsProviderPlan);
} }
if (subscriptionItemOptionsList.Count > 0) if (subscriptionItemOptionsList.Count > 0)
{ {
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId,
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList }); new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
} }
} }

View File

@ -13,6 +13,7 @@ using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -1011,26 +1012,192 @@ public class ProviderBillingServiceTests
#endregion #endregion
#region UpdateSeatMinimums #region ChangePlan
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task UpdateSeatMinimums_NullProvider_ThrowsArgumentNullException( public async Task ChangePlan_NullProviderPlan_ThrowsBadRequestException(
SutProvider<ProviderBillingService> sutProvider) => ChangeProviderPlanCommand command,
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.UpdateSeatMinimums(null, 0, 0)); SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
providerPlanRepository.GetByIdAsync(Arg.Any<Guid>()).Returns((ProviderPlan)null);
// Act
var actual = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.ChangePlan(command));
// Assert
Assert.Equal("Provider plan not found.", actual.Message);
}
[Theory, BitAutoData]
public async Task ChangePlan_ProviderNotFound_DoesNothing(
ChangeProviderPlanCommand command,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var existingPlan = new ProviderPlan
{
Id = command.ProviderPlanId,
PlanType = command.NewPlan,
PurchasedSeats = 0,
AllocatedSeats = 0,
SeatMinimum = 0
};
providerPlanRepository
.GetByIdAsync(Arg.Is<Guid>(p => p == command.ProviderPlanId))
.Returns(existingPlan);
// Act
await sutProvider.Sut.ChangePlan(command);
// Assert
await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any<ProviderPlan>());
await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Theory, BitAutoData]
public async Task ChangePlan_SameProviderPlan_DoesNothing(
ChangeProviderPlanCommand command,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var existingPlan = new ProviderPlan
{
Id = command.ProviderPlanId,
PlanType = command.NewPlan,
PurchasedSeats = 0,
AllocatedSeats = 0,
SeatMinimum = 0
};
providerPlanRepository
.GetByIdAsync(Arg.Is<Guid>(p => p == command.ProviderPlanId))
.Returns(existingPlan);
// Act
await sutProvider.Sut.ChangePlan(command);
// Assert
await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any<ProviderPlan>());
await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Theory, BitAutoData]
public async Task ChangePlan_UpdatesSubscriptionCorrectly(
Guid providerPlanId,
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var existingPlan = new ProviderPlan
{
Id = providerPlanId,
ProviderId = provider.Id,
PlanType = PlanType.EnterpriseAnnually,
PurchasedSeats = 2,
AllocatedSeats = 10,
SeatMinimum = 8
};
providerPlanRepository
.GetByIdAsync(Arg.Is<Guid>(p => p == providerPlanId))
.Returns(existingPlan);
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(Arg.Is(existingPlan.ProviderId)).Returns(provider);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.ProviderSubscriptionGetAsync(
Arg.Is(provider.GatewaySubscriptionId),
Arg.Is(provider.Id))
.Returns(new Subscription
{
Id = provider.GatewaySubscriptionId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Id = "si_ent_annual",
Price = new Price
{
Id = StaticStore.GetPlan(PlanType.EnterpriseAnnually).PasswordManager
.StripeProviderPortalSeatPlanId
},
Quantity = 10
}
]
}
});
var command =
new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId);
// Act
await sutProvider.Sut.ChangePlan(command);
// Assert
await providerPlanRepository.Received(1)
.ReplaceAsync(Arg.Is<ProviderPlan>(p => p.PlanType == PlanType.EnterpriseMonthly));
await stripeAdapter.Received(1)
.SubscriptionUpdateAsync(
Arg.Is(provider.GatewaySubscriptionId),
Arg.Is<SubscriptionUpdateOptions>(p =>
p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1));
var newPlanCfg = StaticStore.GetPlan(command.NewPlan);
await stripeAdapter.Received(1)
.SubscriptionUpdateAsync(
Arg.Is(provider.GatewaySubscriptionId),
Arg.Is<SubscriptionUpdateOptions>(p =>
p.Items.Count(si =>
si.Price == newPlanCfg.PasswordManager.StripeProviderPortalSeatPlanId &&
si.Deleted == default &&
si.Quantity == 10) == 1));
}
#endregion
#region UpdateSeatMinimums
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task UpdateSeatMinimums_NegativeSeatMinimum_ThrowsBadRequestException( public async Task UpdateSeatMinimums_NegativeSeatMinimum_ThrowsBadRequestException(
Provider provider, Provider provider,
SutProvider<ProviderBillingService> sutProvider) => SutProvider<ProviderBillingService> sutProvider)
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSeatMinimums(provider, -10, 100)); {
// Arrange
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(PlanType.TeamsMonthly, -10),
(PlanType.EnterpriseMonthly, 50)
]);
// Act
var actual = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSeatMinimums(command));
// Assert
Assert.Equal("Provider seat minimums must be at least 0.", actual.Message);
}
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task UpdateSeatMinimums_NoPurchasedSeats_AllocatedHigherThanIncomingMinimum_UpdatesPurchasedSeats_SyncsStripeWithNewSeatMinimum( public async Task UpdateSeatMinimums_NoPurchasedSeats_AllocatedHigherThanIncomingMinimum_UpdatesPurchasedSeats_SyncsStripeWithNewSeatMinimum(
Provider provider, Provider provider,
SutProvider<ProviderBillingService> sutProvider) SutProvider<ProviderBillingService> sutProvider)
{ {
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>(); var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
@ -1058,7 +1225,9 @@ public class ProviderBillingServiceTests
} }
}; };
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); stripeAdapter.ProviderSubscriptionGetAsync(
provider.GatewaySubscriptionId,
provider.Id).Returns(subscription);
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -1066,10 +1235,21 @@ public class ProviderBillingServiceTests
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0, AllocatedSeats = 25 } new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0, AllocatedSeats = 25 }
}; };
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
await sutProvider.Sut.UpdateSeatMinimums(provider, 30, 20); var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(PlanType.EnterpriseMonthly, 30),
(PlanType.TeamsMonthly, 20)
]);
// Act
await sutProvider.Sut.UpdateSeatMinimums(command);
// Assert
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 30)); providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 30));
@ -1091,8 +1271,11 @@ public class ProviderBillingServiceTests
Provider provider, Provider provider,
SutProvider<ProviderBillingService> sutProvider) SutProvider<ProviderBillingService> sutProvider)
{ {
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>(); var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
@ -1120,7 +1303,7 @@ public class ProviderBillingServiceTests
} }
}; };
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -1130,8 +1313,18 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
await sutProvider.Sut.UpdateSeatMinimums(provider, 70, 50); var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(PlanType.EnterpriseMonthly, 70),
(PlanType.TeamsMonthly, 50)
]);
// Act
await sutProvider.Sut.UpdateSeatMinimums(command);
// Assert
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70)); providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70));
@ -1153,8 +1346,11 @@ public class ProviderBillingServiceTests
Provider provider, Provider provider,
SutProvider<ProviderBillingService> sutProvider) SutProvider<ProviderBillingService> sutProvider)
{ {
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>(); var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
@ -1182,7 +1378,7 @@ public class ProviderBillingServiceTests
} }
}; };
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -1192,8 +1388,18 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
await sutProvider.Sut.UpdateSeatMinimums(provider, 60, 60); var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(PlanType.EnterpriseMonthly, 60),
(PlanType.TeamsMonthly, 60)
]);
// Act
await sutProvider.Sut.UpdateSeatMinimums(command);
// Assert
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10)); providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10));
@ -1209,8 +1415,11 @@ public class ProviderBillingServiceTests
Provider provider, Provider provider,
SutProvider<ProviderBillingService> sutProvider) SutProvider<ProviderBillingService> sutProvider)
{ {
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>(); var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
@ -1238,7 +1447,7 @@ public class ProviderBillingServiceTests
} }
}; };
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -1248,8 +1457,18 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
await sutProvider.Sut.UpdateSeatMinimums(provider, 80, 80); var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(PlanType.EnterpriseMonthly, 80),
(PlanType.TeamsMonthly, 80)
]);
// Act
await sutProvider.Sut.UpdateSeatMinimums(command);
// Assert
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0)); providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0));
@ -1271,8 +1490,11 @@ public class ProviderBillingServiceTests
Provider provider, Provider provider,
SutProvider<ProviderBillingService> sutProvider) SutProvider<ProviderBillingService> sutProvider)
{ {
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>(); var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
@ -1300,7 +1522,7 @@ public class ProviderBillingServiceTests
} }
}; };
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -1310,8 +1532,18 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
await sutProvider.Sut.UpdateSeatMinimums(provider, 70, 30); var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(PlanType.EnterpriseMonthly, 70),
(PlanType.TeamsMonthly, 30)
]);
// Act
await sutProvider.Sut.UpdateSeatMinimums(command);
// Assert
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70)); providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70));

View File

@ -9,7 +9,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" /> <PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" /> <PackageReference Include="xunit" Version="$(XUnitVersion)" />

View File

@ -14,6 +14,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -290,26 +291,40 @@ public class ProvidersController : Controller
var providerPlans = await _providerPlanRepository.GetByProviderId(id); var providerPlans = await _providerPlanRepository.GetByProviderId(id);
if (providerPlans.Count == 0) switch (provider.Type)
{ {
var newProviderPlans = new List<ProviderPlan> case ProviderType.Msp:
var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum),
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
]);
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
break;
case ProviderType.MultiOrganizationEnterprise:
{ {
new () { ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = model.TeamsMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }, var existingMoePlan = providerPlans.Single();
new () { ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = model.EnterpriseMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }
};
foreach (var newProviderPlan in newProviderPlans) // 1. Change the plan and take over any old values.
{ var changeMoePlanCommand = new ChangeProviderPlanCommand(
await _providerPlanRepository.CreateAsync(newProviderPlan); existingMoePlan.Id,
model.Plan!.Value,
provider.GatewaySubscriptionId);
await _providerBillingService.ChangePlan(changeMoePlanCommand);
// 2. Update the seat minimums.
var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
]);
await _providerBillingService.UpdateSeatMinimums(updateMoeSeatMinimumsCommand);
break;
} }
} }
else
{
await _providerBillingService.UpdateSeatMinimums(
provider,
model.EnterpriseMonthlySeatMinimum,
model.TeamsMonthlySeatMinimum);
}
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
} }

View File

@ -33,6 +33,13 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
GatewayCustomerUrl = gatewayCustomerUrl; GatewayCustomerUrl = gatewayCustomerUrl;
GatewaySubscriptionUrl = gatewaySubscriptionUrl; GatewaySubscriptionUrl = gatewaySubscriptionUrl;
Type = provider.Type; Type = provider.Type;
if (Type == ProviderType.MultiOrganizationEnterprise)
{
var plan = providerPlans.SingleOrDefault();
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
Plan = plan?.PlanType;
}
} }
[Display(Name = "Billing Email")] [Display(Name = "Billing Email")]
@ -58,13 +65,24 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
[Display(Name = "Provider Type")] [Display(Name = "Provider Type")]
public ProviderType Type { get; set; } public ProviderType Type { get; set; }
[Display(Name = "Plan")]
public PlanType? Plan { get; set; }
[Display(Name = "Enterprise Seats Minimum")]
public int? EnterpriseMinimumSeats { get; set; }
public virtual Provider ToProvider(Provider existingProvider) public virtual Provider ToProvider(Provider existingProvider)
{ {
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim(); existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim();
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim(); existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim();
switch (Type)
{
case ProviderType.Msp:
existingProvider.Gateway = Gateway; existingProvider.Gateway = Gateway;
existingProvider.GatewayCustomerId = GatewayCustomerId; existingProvider.GatewayCustomerId = GatewayCustomerId;
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId; existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
break;
}
return existingProvider; return existingProvider;
} }
@ -82,6 +100,23 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
yield return new ValidationResult($"The {billingEmailDisplayName} field is required."); yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
} }
break; break;
case ProviderType.MultiOrganizationEnterprise:
if (Plan == null)
{
var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan);
yield return new ValidationResult($"The {displayName} field is required.");
}
if (EnterpriseMinimumSeats == null)
{
var displayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMinimumSeats);
yield return new ValidationResult($"The {displayName} field is required.");
}
if (EnterpriseMinimumSeats < 0)
{
var displayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMinimumSeats);
yield return new ValidationResult($"The {displayName} field cannot be less than 0.");
}
break;
} }
} }
} }

View File

@ -1,6 +1,9 @@
@using Bit.Admin.Enums; @using Bit.Admin.Enums;
@using Bit.Core @using Bit.Core
@using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core.Billing.Enums
@using Bit.Core.Billing.Extensions @using Bit.Core.Billing.Extensions
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject Bit.Admin.Services.IAccessControlService AccessControlService @inject Bit.Admin.Services.IAccessControlService AccessControlService
@inject Bit.Core.Services.IFeatureService FeatureService @inject Bit.Core.Services.IFeatureService FeatureService
@ -46,6 +49,10 @@
</div> </div>
</div> </div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable()) @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable())
{
switch (Model.Provider.Type)
{
case ProviderType.Msp:
{ {
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
@ -101,6 +108,39 @@
</div> </div>
</div> </div>
</div> </div>
break;
}
case ProviderType.MultiOrganizationEnterprise:
{
@if (FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises) && Model.Provider.Type == ProviderType.MultiOrganizationEnterprise)
{
<div class="row">
<div class="col-sm">
<div class="form-group">
@{
var multiOrgPlans = new List<PlanType>
{
PlanType.EnterpriseAnnually,
PlanType.EnterpriseMonthly
};
}
<label asp-for="Plan"></label>
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
<option value="">--</option>
</select>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="EnterpriseMinimumSeats"></label>
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
</div>
</div>
</div>
}
break;
}
}
} }
</form> </form>
@await Html.PartialAsync("Organizations", Model) @await Html.PartialAsync("Organizations", Model)

View File

@ -4,6 +4,7 @@ using Bit.Admin.Enums;
using Bit.Admin.Models; using Bit.Admin.Models;
using Bit.Admin.Services; using Bit.Admin.Services;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -24,6 +25,8 @@ public class UsersController : Controller
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IAccessControlService _accessControlService; private readonly IAccessControlService _accessControlService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IUserService _userService;
private readonly IFeatureService _featureService;
public UsersController( public UsersController(
IUserRepository userRepository, IUserRepository userRepository,
@ -31,7 +34,9 @@ public class UsersController : Controller
IPaymentService paymentService, IPaymentService paymentService,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IAccessControlService accessControlService, IAccessControlService accessControlService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserService userService,
IFeatureService featureService)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_cipherRepository = cipherRepository; _cipherRepository = cipherRepository;
@ -39,6 +44,8 @@ public class UsersController : Controller
_globalSettings = globalSettings; _globalSettings = globalSettings;
_accessControlService = accessControlService; _accessControlService = accessControlService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_userService = userService;
_featureService = featureService;
} }
[RequirePermission(Permission.User_List_View)] [RequirePermission(Permission.User_List_View)]
@ -82,8 +89,8 @@ public class UsersController : Controller
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id); var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers)); return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, verifiedDomain));
} }
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
@ -99,7 +106,8 @@ public class UsersController : Controller
var billingInfo = await _paymentService.GetBillingAsync(user); var billingInfo = await _paymentService.GetBillingAsync(user);
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user); var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings)); var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain));
} }
[HttpPost] [HttpPost]
@ -153,4 +161,12 @@ public class UsersController : Controller
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
// TODO: Feature flag to be removed in PM-14207
private async Task<bool?> AccountDeprovisioningEnabled(Guid userId)
{
return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
? await _userService.IsManagedByAnyOrganizationAsync(userId)
: null;
}
} }

View File

@ -20,9 +20,11 @@ public class UserEditModel
IEnumerable<Cipher> ciphers, IEnumerable<Cipher> ciphers,
BillingInfo billingInfo, BillingInfo billingInfo,
BillingHistoryInfo billingHistoryInfo, BillingHistoryInfo billingHistoryInfo,
GlobalSettings globalSettings) GlobalSettings globalSettings,
bool? domainVerified
)
{ {
User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers); User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, domainVerified);
BillingInfo = billingInfo; BillingInfo = billingInfo;
BillingHistoryInfo = billingHistoryInfo; BillingHistoryInfo = billingHistoryInfo;

View File

@ -14,6 +14,7 @@ public class UserViewModel
public bool Premium { get; } public bool Premium { get; }
public short? MaxStorageGb { get; } public short? MaxStorageGb { get; }
public bool EmailVerified { get; } public bool EmailVerified { get; }
public bool? DomainVerified { get; }
public bool TwoFactorEnabled { get; } public bool TwoFactorEnabled { get; }
public DateTime AccountRevisionDate { get; } public DateTime AccountRevisionDate { get; }
public DateTime RevisionDate { get; } public DateTime RevisionDate { get; }
@ -35,6 +36,7 @@ public class UserViewModel
bool premium, bool premium,
short? maxStorageGb, short? maxStorageGb,
bool emailVerified, bool emailVerified,
bool? domainVerified,
bool twoFactorEnabled, bool twoFactorEnabled,
DateTime accountRevisionDate, DateTime accountRevisionDate,
DateTime revisionDate, DateTime revisionDate,
@ -56,6 +58,7 @@ public class UserViewModel
Premium = premium; Premium = premium;
MaxStorageGb = maxStorageGb; MaxStorageGb = maxStorageGb;
EmailVerified = emailVerified; EmailVerified = emailVerified;
DomainVerified = domainVerified;
TwoFactorEnabled = twoFactorEnabled; TwoFactorEnabled = twoFactorEnabled;
AccountRevisionDate = accountRevisionDate; AccountRevisionDate = accountRevisionDate;
RevisionDate = revisionDate; RevisionDate = revisionDate;
@ -73,10 +76,10 @@ public class UserViewModel
public static IEnumerable<UserViewModel> MapViewModels( public static IEnumerable<UserViewModel> MapViewModels(
IEnumerable<User> users, IEnumerable<User> users,
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) => IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) =>
users.Select(user => MapViewModel(user, lookup)); users.Select(user => MapViewModel(user, lookup, false));
public static UserViewModel MapViewModel(User user, public static UserViewModel MapViewModel(User user,
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) => IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup, bool? domainVerified) =>
new( new(
user.Id, user.Id,
user.Name, user.Name,
@ -86,6 +89,7 @@ public class UserViewModel
user.Premium, user.Premium,
user.MaxStorageGb, user.MaxStorageGb,
user.EmailVerified, user.EmailVerified,
domainVerified,
IsTwoFactorEnabled(user, lookup), IsTwoFactorEnabled(user, lookup),
user.AccountRevisionDate, user.AccountRevisionDate,
user.RevisionDate, user.RevisionDate,
@ -100,9 +104,9 @@ public class UserViewModel
Array.Empty<Cipher>()); Array.Empty<Cipher>());
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled) => public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled) =>
MapViewModel(user, isTwoFactorEnabled, Array.Empty<Cipher>()); MapViewModel(user, isTwoFactorEnabled, Array.Empty<Cipher>(), false);
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable<Cipher> ciphers) => public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable<Cipher> ciphers, bool? domainVerified) =>
new( new(
user.Id, user.Id,
user.Name, user.Name,
@ -112,6 +116,7 @@ public class UserViewModel
user.Premium, user.Premium,
user.MaxStorageGb, user.MaxStorageGb,
user.EmailVerified, user.EmailVerified,
domainVerified,
isTwoFactorEnabled, isTwoFactorEnabled,
user.AccountRevisionDate, user.AccountRevisionDate,
user.RevisionDate, user.RevisionDate,

View File

@ -110,6 +110,7 @@ public static class RolePermissionMapping
Permission.User_Licensing_View, Permission.User_Licensing_View,
Permission.User_Billing_View, Permission.User_Billing_View,
Permission.User_Billing_LaunchGateway, Permission.User_Billing_LaunchGateway,
Permission.User_Delete,
Permission.Org_List_View, Permission.Org_List_View,
Permission.Org_OrgInformation_View, Permission.Org_OrgInformation_View,
Permission.Org_GeneralDetails_View, Permission.Org_GeneralDetails_View,

View File

@ -1,4 +1,4 @@
@model UserViewModel @model UserViewModel
<dl class="row"> <dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt> <dt class="col-sm-4 col-lg-3">Id</dt>
<dd class="col-sm-8 col-lg-9"><code>@Model.Id</code></dd> <dd class="col-sm-8 col-lg-9"><code>@Model.Id</code></dd>
@ -12,6 +12,11 @@
<dt class="col-sm-4 col-lg-3">Email Verified</dt> <dt class="col-sm-4 col-lg-3">Email Verified</dt>
<dd class="col-sm-8 col-lg-9">@(Model.EmailVerified ? "Yes" : "No")</dd> <dd class="col-sm-8 col-lg-9">@(Model.EmailVerified ? "Yes" : "No")</dd>
@if(Model.DomainVerified.HasValue){
<dt class="col-sm-4 col-lg-3">Domain Verified</dt>
<dd class="col-sm-8 col-lg-9">@(Model.DomainVerified.Value == true ? "Yes" : "No")</dd>
}
<dt class="col-sm-4 col-lg-3">Using 2FA</dt> <dt class="col-sm-4 col-lg-3">Using 2FA</dt>
<dd class="col-sm-8 col-lg-9">@(Model.TwoFactorEnabled ? "Yes" : "No")</dd> <dd class="col-sm-8 col-lg-9">@(Model.TwoFactorEnabled ? "Yes" : "No")</dd>

View File

@ -1,6 +1,5 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
@ -545,7 +544,7 @@ public class OrganizationUsersController : Controller
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("{id}/delete-account")] [HttpDelete("{id}/delete-account")]
[HttpPost("{id}/delete-account")] [HttpPost("{id}/delete-account")]
public async Task DeleteAccount(Guid orgId, Guid id, [FromBody] SecretVerificationRequestModel model) public async Task DeleteAccount(Guid orgId, Guid id)
{ {
if (!await _currentContext.ManageUsers(orgId)) if (!await _currentContext.ManageUsers(orgId))
{ {
@ -558,19 +557,13 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
if (!await _userService.VerifySecretAsync(currentUser, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
} }
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("delete-account")] [HttpDelete("delete-account")]
[HttpPost("delete-account")] [HttpPost("delete-account")]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] SecureOrganizationUserBulkRequestModel model) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{ {
if (!await _currentContext.ManageUsers(orgId)) if (!await _currentContext.ManageUsers(orgId))
{ {
@ -583,12 +576,6 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
if (!await _userService.VerifySecretAsync(currentUser, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r => return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>

View File

@ -16,6 +16,7 @@ using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using AdminConsoleEntities = Bit.Core.AdminConsole.Entities;
namespace Bit.Api.AdminConsole.Controllers; namespace Bit.Api.AdminConsole.Controllers;
@ -55,17 +56,16 @@ public class PoliciesController : Controller
} }
[HttpGet("{type}")] [HttpGet("{type}")]
public async Task<PolicyResponseModel> Get(string orgId, int type) public async Task<PolicyResponseModel> Get(Guid orgId, int type)
{ {
var orgIdGuid = new Guid(orgId); if (!await _currentContext.ManagePolicies(orgId))
if (!await _currentContext.ManagePolicies(orgIdGuid))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgIdGuid, (PolicyType)type); var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
if (policy == null) if (policy == null)
{ {
throw new NotFoundException(); return new PolicyResponseModel(new AdminConsoleEntities.Policy() { Type = (PolicyType)type, Enabled = false });
} }
return new PolicyResponseModel(policy); return new PolicyResponseModel(policy);

View File

@ -1,10 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request.Accounts;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class SecureOrganizationUserBulkRequestModel : SecretVerificationRequestModel
{
[Required]
public IEnumerable<Guid> Ids { get; set; }
}

View File

@ -35,7 +35,7 @@
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" /> <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" /> <PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" /> <PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.8.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -148,6 +148,13 @@ public class AccountsController : Controller
throw new BadRequestException("MasterPasswordHash", "Invalid password."); throw new BadRequestException("MasterPasswordHash", "Invalid password.");
} }
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
{
throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.");
}
await _userService.InitiateEmailChangeAsync(user, model.NewEmail); await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
} }
@ -165,6 +172,13 @@ public class AccountsController : Controller
throw new BadRequestException("You cannot change your email when using Key Connector."); throw new BadRequestException("You cannot change your email when using Key Connector.");
} }
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
{
throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.");
}
var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail, var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail,
model.NewMasterPasswordHash, model.Token, model.Key); model.NewMasterPasswordHash, model.Token, model.Key);
if (result.Succeeded) if (result.Succeeded)
@ -566,6 +580,13 @@ public class AccountsController : Controller
} }
else else
{ {
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
{
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
}
var result = await _userService.DeleteAsync(user); var result = await _userService.DeleteAsync(user);
if (result.Succeeded) if (result.Succeeded)
{ {

View File

@ -26,7 +26,7 @@ public class OrganizationBillingController(
[HttpGet("metadata")] [HttpGet("metadata")]
public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId) public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId)
{ {
if (!await currentContext.AccessMembersTab(organizationId)) if (!await currentContext.OrganizationUser(organizationId))
{ {
return Error.Unauthorized(); return Error.Unauthorized();
} }

View File

@ -93,7 +93,8 @@ public class ProviderBillingController(
subscription, subscription,
providerPlans, providerPlans,
taxInformation, taxInformation,
subscriptionSuspension); subscriptionSuspension,
provider);
return TypedResults.Ok(response); return TypedResults.Ok(response);
} }

View File

@ -12,7 +12,7 @@ public class CreateClientOrganizationRequestBody
[Required(ErrorMessage = "'ownerEmail' must be provided")] [Required(ErrorMessage = "'ownerEmail' must be provided")]
public string OwnerEmail { get; set; } public string OwnerEmail { get; set; }
[EnumMatches<PlanType>(PlanType.TeamsMonthly, PlanType.EnterpriseMonthly, ErrorMessage = "'planType' must be Teams (Monthly) or Enterprise (Monthly)")] [EnumMatches<PlanType>(PlanType.TeamsMonthly, PlanType.EnterpriseMonthly, PlanType.EnterpriseAnnually, ErrorMessage = "'planType' must be Teams (Monthly), Enterprise (Monthly) or Enterprise (Annually)")]
public PlanType PlanType { get; set; } public PlanType PlanType { get; set; }
[Range(1, int.MaxValue, ErrorMessage = "'seats' must be greater than 0")] [Range(1, int.MaxValue, ErrorMessage = "'seats' must be greater than 0")]

View File

@ -4,10 +4,14 @@ namespace Bit.Api.Billing.Models.Responses;
public record OrganizationMetadataResponse( public record OrganizationMetadataResponse(
bool IsEligibleForSelfHost, bool IsEligibleForSelfHost,
bool IsOnSecretsManagerStandalone) bool IsManaged,
bool IsOnSecretsManagerStandalone,
bool IsSubscriptionUnpaid)
{ {
public static OrganizationMetadataResponse From(OrganizationMetadata metadata) public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
=> new( => new(
metadata.IsEligibleForSelfHost, metadata.IsEligibleForSelfHost,
metadata.IsOnSecretsManagerStandalone); metadata.IsManaged,
metadata.IsOnSecretsManagerStandalone,
metadata.IsSubscriptionUnpaid);
} }

View File

@ -1,4 +1,7 @@
using Bit.Core.Billing.Entities; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Stripe; using Stripe;
@ -14,7 +17,8 @@ public record ProviderSubscriptionResponse(
decimal AccountCredit, decimal AccountCredit,
TaxInformation TaxInformation, TaxInformation TaxInformation,
DateTime? CancelAt, DateTime? CancelAt,
SubscriptionSuspension Suspension) SubscriptionSuspension Suspension,
ProviderType ProviderType)
{ {
private const string _annualCadence = "Annual"; private const string _annualCadence = "Annual";
private const string _monthlyCadence = "Monthly"; private const string _monthlyCadence = "Monthly";
@ -23,7 +27,8 @@ public record ProviderSubscriptionResponse(
Subscription subscription, Subscription subscription,
ICollection<ProviderPlan> providerPlans, ICollection<ProviderPlan> providerPlans,
TaxInformation taxInformation, TaxInformation taxInformation,
SubscriptionSuspension subscriptionSuspension) SubscriptionSuspension subscriptionSuspension,
Provider provider)
{ {
var providerPlanResponses = providerPlans var providerPlanResponses = providerPlans
.Where(providerPlan => providerPlan.IsConfigured()) .Where(providerPlan => providerPlan.IsConfigured())
@ -35,6 +40,8 @@ public record ProviderSubscriptionResponse(
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence; var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
return new ProviderPlanResponse( return new ProviderPlanResponse(
plan.Name, plan.Name,
plan.Type,
plan.ProductTier,
configuredProviderPlan.SeatMinimum, configuredProviderPlan.SeatMinimum,
configuredProviderPlan.PurchasedSeats, configuredProviderPlan.PurchasedSeats,
configuredProviderPlan.AssignedSeats, configuredProviderPlan.AssignedSeats,
@ -53,12 +60,15 @@ public record ProviderSubscriptionResponse(
accountCredit, accountCredit,
taxInformation, taxInformation,
subscription.CancelAt, subscription.CancelAt,
subscriptionSuspension); subscriptionSuspension,
provider.Type);
} }
} }
public record ProviderPlanResponse( public record ProviderPlanResponse(
string PlanName, string PlanName,
PlanType Type,
ProductTierType ProductTier,
int SeatMinimum, int SeatMinimum,
int PurchasedSeats, int PurchasedSeats,
int AssignedSeats, int AssignedSeats,

View File

@ -196,8 +196,8 @@ public class DevicesController : Controller
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[HttpPost("{id}/delete")] [HttpPost("{id}/deactivate")]
public async Task Delete(string id) public async Task Deactivate(string id)
{ {
var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value); var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value);
if (device == null) if (device == null)
@ -205,7 +205,7 @@ public class DevicesController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
await _deviceService.DeleteAsync(device); await _deviceService.DeactivateAsync(device);
} }
[AllowAnonymous] [AllowAnonymous]

View File

@ -1,7 +1,9 @@
using Bit.Api.Vault.Models.Response; using Bit.Api.Vault.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -10,6 +12,7 @@ using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tools.Repositories; using Bit.Core.Tools.Repositories;
using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -30,6 +33,8 @@ public class SyncController : Controller
private readonly IPolicyRepository _policyRepository; private readonly IPolicyRepository _policyRepository;
private readonly ISendRepository _sendRepository; private readonly ISendRepository _sendRepository;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly ICurrentContext _currentContext;
private readonly Version _sshKeyCipherMinimumVersion = new(Constants.SSHKeyCipherMinimumVersion);
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
public SyncController( public SyncController(
@ -43,6 +48,7 @@ public class SyncController : Controller
IPolicyRepository policyRepository, IPolicyRepository policyRepository,
ISendRepository sendRepository, ISendRepository sendRepository,
GlobalSettings globalSettings, GlobalSettings globalSettings,
ICurrentContext currentContext,
IFeatureService featureService) IFeatureService featureService)
{ {
_userService = userService; _userService = userService;
@ -55,6 +61,7 @@ public class SyncController : Controller
_policyRepository = policyRepository; _policyRepository = policyRepository;
_sendRepository = sendRepository; _sendRepository = sendRepository;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_currentContext = currentContext;
_featureService = featureService; _featureService = featureService;
} }
@ -77,7 +84,8 @@ public class SyncController : Controller
var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled); var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled);
var folders = await _folderRepository.GetManyByUserIdAsync(user.Id); var folders = await _folderRepository.GetManyByUserIdAsync(user.Id);
var ciphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: hasEnabledOrgs); var allCiphers = await _cipherRepository.GetManyByUserIdAsync(user.Id, withOrganizations: hasEnabledOrgs);
var ciphers = FilterSSHKeys(allCiphers);
var sends = await _sendRepository.GetManyByUserIdAsync(user.Id); var sends = await _sendRepository.GetManyByUserIdAsync(user.Id);
IEnumerable<CollectionDetails> collections = null; IEnumerable<CollectionDetails> collections = null;
@ -101,4 +109,16 @@ public class SyncController : Controller
folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends); folders, collections, ciphers, collectionCiphersGroupDict, excludeDomains, policies, sends);
return response; return response;
} }
private ICollection<CipherDetails> FilterSSHKeys(ICollection<CipherDetails> ciphers)
{
if (_currentContext.ClientVersion >= _sshKeyCipherMinimumVersion)
{
return ciphers;
}
else
{
return ciphers.Where(c => c.Type != Core.Vault.Enums.CipherType.SSHKey).ToList();
}
}
} }

View File

@ -0,0 +1,26 @@
using Bit.Core.Utilities;
using Bit.Core.Vault.Models.Data;
namespace Bit.Api.Vault.Models;
public class CipherSSHKeyModel
{
public CipherSSHKeyModel() { }
public CipherSSHKeyModel(CipherSSHKeyData data)
{
PrivateKey = data.PrivateKey;
PublicKey = data.PublicKey;
KeyFingerprint = data.KeyFingerprint;
}
[EncryptedString]
[EncryptedStringLength(5000)]
public string PrivateKey { get; set; }
[EncryptedString]
[EncryptedStringLength(5000)]
public string PublicKey { get; set; }
[EncryptedString]
[EncryptedStringLength(1000)]
public string KeyFingerprint { get; set; }
}

View File

@ -37,6 +37,7 @@ public class CipherRequestModel
public CipherCardModel Card { get; set; } public CipherCardModel Card { get; set; }
public CipherIdentityModel Identity { get; set; } public CipherIdentityModel Identity { get; set; }
public CipherSecureNoteModel SecureNote { get; set; } public CipherSecureNoteModel SecureNote { get; set; }
public CipherSSHKeyModel SSHKey { get; set; }
public DateTime? LastKnownRevisionDate { get; set; } = null; public DateTime? LastKnownRevisionDate { get; set; } = null;
public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true) public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true)
@ -82,6 +83,9 @@ public class CipherRequestModel
case CipherType.SecureNote: case CipherType.SecureNote:
existingCipher.Data = JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull); existingCipher.Data = JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull);
break; break;
case CipherType.SSHKey:
existingCipher.Data = JsonSerializer.Serialize(ToCipherSSHKeyData(), JsonHelpers.IgnoreWritingNull);
break;
default: default:
throw new ArgumentException("Unsupported type: " + nameof(Type) + "."); throw new ArgumentException("Unsupported type: " + nameof(Type) + ".");
} }
@ -230,6 +234,21 @@ public class CipherRequestModel
Type = SecureNote.Type, Type = SecureNote.Type,
}; };
} }
private CipherSSHKeyData ToCipherSSHKeyData()
{
return new CipherSSHKeyData
{
Name = Name,
Notes = Notes,
Fields = Fields?.Select(f => f.ToCipherFieldData()),
PasswordHistory = PasswordHistory?.Select(ph => ph.ToCipherPasswordHistoryData()),
PrivateKey = SSHKey.PrivateKey,
PublicKey = SSHKey.PublicKey,
KeyFingerprint = SSHKey.KeyFingerprint,
};
}
} }
public class CipherWithIdRequestModel : CipherRequestModel public class CipherWithIdRequestModel : CipherRequestModel

View File

@ -48,6 +48,12 @@ public class CipherMiniResponseModel : ResponseModel
cipherData = identityData; cipherData = identityData;
Identity = new CipherIdentityModel(identityData); Identity = new CipherIdentityModel(identityData);
break; break;
case CipherType.SSHKey:
var sshKeyData = JsonSerializer.Deserialize<CipherSSHKeyData>(cipher.Data);
Data = sshKeyData;
cipherData = sshKeyData;
SSHKey = new CipherSSHKeyModel(sshKeyData);
break;
default: default:
throw new ArgumentException("Unsupported " + nameof(Type) + "."); throw new ArgumentException("Unsupported " + nameof(Type) + ".");
} }
@ -76,6 +82,7 @@ public class CipherMiniResponseModel : ResponseModel
public CipherCardModel Card { get; set; } public CipherCardModel Card { get; set; }
public CipherIdentityModel Identity { get; set; } public CipherIdentityModel Identity { get; set; }
public CipherSecureNoteModel SecureNote { get; set; } public CipherSecureNoteModel SecureNote { get; set; }
public CipherSSHKeyModel SSHKey { get; set; }
public IEnumerable<CipherFieldModel> Fields { get; set; } public IEnumerable<CipherFieldModel> Fields { get; set; }
public IEnumerable<CipherPasswordHistoryModel> PasswordHistory { get; set; } public IEnumerable<CipherPasswordHistoryModel> PasswordHistory { get; set; }
public IEnumerable<AttachmentResponseModel> Attachments { get; set; } public IEnumerable<AttachmentResponseModel> Attachments { get; set; }

View File

@ -0,0 +1,7 @@
using Bit.Core.Models.Mail;
namespace Bit.Core.Auth.Models.Mail;
public class CannotDeleteManagedAccountViewModel : BaseMailModel
{
}

View File

@ -11,11 +11,10 @@ namespace Bit.Core.Billing.Extensions;
public static class BillingExtensions public static class BillingExtensions
{ {
public static bool IsBillable(this Provider provider) => public static bool IsBillable(this Provider provider) =>
provider is provider.SupportsConsolidatedBilling() && provider.Status == ProviderStatusType.Billable;
{
Type: ProviderType.Msp, public static bool SupportsConsolidatedBilling(this Provider provider)
Status: ProviderStatusType.Billable => provider.Type is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
};
public static bool IsValidClient(this Organization organization) public static bool IsValidClient(this Organization organization)
=> organization is => organization is
@ -44,5 +43,5 @@ public static class BillingExtensions
}; };
public static bool SupportsConsolidatedBilling(this PlanType planType) public static bool SupportsConsolidatedBilling(this PlanType planType)
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly; => planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually;
} }

View File

@ -8,6 +8,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Migration.Models; using Bit.Core.Billing.Migration.Models;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -307,7 +308,14 @@ public class ProviderMigrator(
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)? .FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)?
.SeatMinimum ?? 0; .SeatMinimum ?? 0;
await providerBillingService.UpdateSeatMinimums(provider, enterpriseSeatMinimum, teamsSeatMinimum); var updateSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: enterpriseSeatMinimum),
(Plan: PlanType.TeamsMonthly, SeatsMinimum: teamsSeatMinimum)
]);
await providerBillingService.UpdateSeatMinimums(updateSeatMinimumsCommand);
logger.LogInformation( logger.LogInformation(
"CB: Updated Stripe subscription for provider ({ProviderID}) with current seat minimums", provider.Id); "CB: Updated Stripe subscription for provider ({ProviderID}) with current seat minimums", provider.Id);
@ -325,6 +333,8 @@ public class ProviderMigrator(
var organizationCancellationCredit = organizationCustomers.Sum(customer => customer.Balance); var organizationCancellationCredit = organizationCustomers.Sum(customer => customer.Balance);
if (organizationCancellationCredit != 0)
{
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId, await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
new CustomerBalanceTransactionCreateOptions new CustomerBalanceTransactionCreateOptions
{ {
@ -332,6 +342,7 @@ public class ProviderMigrator(
Currency = "USD", Currency = "USD",
Description = "Unused, prorated time for client organization subscriptions." Description = "Unused, prorated time for client organization subscriptions."
}); });
}
var migrationRecords = await Task.WhenAll(organizations.Select(organization => var migrationRecords = await Task.WhenAll(organizations.Select(organization =>
clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id))); clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id)));

View File

@ -2,9 +2,6 @@
public record OrganizationMetadata( public record OrganizationMetadata(
bool IsEligibleForSelfHost, bool IsEligibleForSelfHost,
bool IsOnSecretsManagerStandalone) bool IsManaged,
{ bool IsOnSecretsManagerStandalone,
public static OrganizationMetadata Default() => new( bool IsSubscriptionUnpaid);
IsEligibleForSelfHost: false,
IsOnSecretsManagerStandalone: false);
}

View File

@ -24,6 +24,7 @@ public record TeamsPlan : Plan
Has2fa = true; Has2fa = true;
HasApi = true; HasApi = true;
UsersGetPremium = true; UsersGetPremium = true;
HasScim = true;
UpgradeSortOrder = 3; UpgradeSortOrder = 3;
DisplaySortOrder = 3; DisplaySortOrder = 3;

View File

@ -0,0 +1,8 @@
using Bit.Core.Billing.Enums;
namespace Bit.Core.Billing.Services.Contracts;
public record ChangeProviderPlanCommand(
Guid ProviderPlanId,
PlanType NewPlan,
string GatewaySubscriptionId);

View File

@ -0,0 +1,10 @@
using Bit.Core.Billing.Enums;
namespace Bit.Core.Billing.Services.Contracts;
/// <param name="Id">The ID of the provider to update the seat minimums for.</param>
/// <param name="Configuration">The new seat minimums for the provider.</param>
public record UpdateProviderSeatMinimumsCommand(
Guid Id,
string GatewaySubscriptionId,
IReadOnlyCollection<(PlanType Plan, int SeatsMinimum)> Configuration);

View File

@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Stripe; using Stripe;
@ -89,8 +90,12 @@ public interface IProviderBillingService
Task<Subscription> SetupSubscription( Task<Subscription> SetupSubscription(
Provider provider); Provider provider);
Task UpdateSeatMinimums( /// <summary>
Provider provider, /// Changes the assigned provider plan for the provider.
int enterpriseSeatMinimum, /// </summary>
int teamsSeatMinimum); /// <param name="command">The command to change the provider plan.</param>
/// <returns></returns>
Task ChangePlan(ChangeProviderPlanCommand command);
Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
} }

View File

@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
@ -27,7 +26,6 @@ public class OrganizationBillingService(
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<OrganizationBillingService> logger, ILogger<OrganizationBillingService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IOrganizationBillingService ISubscriberService subscriberService) : IOrganizationBillingService
@ -64,18 +62,18 @@ public class OrganizationBillingService(
return null; return null;
} }
var customer = await subscriberService.GetCustomer(organization, new CustomerGetOptions var customer = await subscriberService.GetCustomer(organization,
{ new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] });
Expand = ["discount.coupon.applies_to"]
});
var subscription = await subscriberService.GetSubscription(organization); var subscription = await subscriberService.GetSubscription(organization);
var isEligibleForSelfHost = await IsEligibleForSelfHost(organization, subscription); var isEligibleForSelfHost = IsEligibleForSelfHost(organization);
var isManaged = organization.Status == OrganizationStatusType.Managed;
var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription); var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription);
var isSubscriptionUnpaid = IsSubscriptionUnpaid(subscription);
return new OrganizationMetadata(isEligibleForSelfHost, isOnSecretsManagerStandalone); return new OrganizationMetadata(isEligibleForSelfHost, isManaged, isOnSecretsManagerStandalone,
isSubscriptionUnpaid);
} }
public async Task UpdatePaymentMethod( public async Task UpdatePaymentMethod(
@ -339,26 +337,12 @@ public class OrganizationBillingService(
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
} }
private async Task<bool> IsEligibleForSelfHost( private static bool IsEligibleForSelfHost(
Organization organization, Organization organization)
Subscription? organizationSubscription)
{ {
if (organization.Status != OrganizationStatusType.Managed) var eligibleSelfHostPlans = StaticStore.Plans.Where(plan => plan.HasSelfHost).Select(plan => plan.Type);
{
return organization.Plan.Contains("Families") ||
organization.Plan.Contains("Enterprise") && IsActive(organizationSubscription);
}
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); return eligibleSelfHostPlans.Contains(organization.PlanType);
var providerSubscription = await subscriberService.GetSubscriptionOrThrow(provider);
return organization.Plan.Contains("Enterprise") && IsActive(providerSubscription);
bool IsActive(Subscription? subscription) => subscription?.Status is
StripeConstants.SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.Trialing or
StripeConstants.SubscriptionStatus.PastDue;
} }
private static bool IsOnSecretsManagerStandalone( private static bool IsOnSecretsManagerStandalone(
@ -392,5 +376,16 @@ public class OrganizationBillingService(
return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any();
} }
private static bool IsSubscriptionUnpaid(Subscription subscription)
{
if (subscription == null)
{
return false;
}
return subscription.Status == "unpaid";
}
#endregion #endregion
} }

View File

@ -22,6 +22,7 @@ public static class Constants
public const int OrganizationSelfHostSubscriptionGracePeriodDays = 60; public const int OrganizationSelfHostSubscriptionGracePeriodDays = 60;
public const string Fido2KeyCipherMinimumVersion = "2023.10.0"; public const string Fido2KeyCipherMinimumVersion = "2023.10.0";
public const string SSHKeyCipherMinimumVersion = "2024.12.0";
/// <summary> /// <summary>
/// Used by IdentityServer to identify our own provider. /// Used by IdentityServer to identify our own provider.
@ -100,13 +101,11 @@ public static class AuthenticationSchemes
public static class FeatureFlagKeys public static class FeatureFlagKeys
{ {
public const string DisplayEuEnvironment = "display-eu-environment";
public const string BrowserFilelessImport = "browser-fileless-import"; public const string BrowserFilelessImport = "browser-fileless-import";
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection"; public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection";
public const string ItemShare = "item-share"; public const string ItemShare = "item-share";
public const string DuoRedirect = "duo-redirect"; public const string DuoRedirect = "duo-redirect";
public const string PM5864DollarThreshold = "PM-5864-dollar-threshold";
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string EnableConsolidatedBilling = "enable-consolidated-billing"; public const string EnableConsolidatedBilling = "enable-consolidated-billing";
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section"; public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
@ -124,6 +123,8 @@ public static class FeatureFlagKeys
public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements"; public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements";
public const string ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner"; public const string ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner";
public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
public const string SSHKeyItemVaultItem = "ssh-key-vault-item";
public const string SSHAgent = "ssh-agent";
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
public const string EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub"; public const string EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub";
public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; public const string IdpAutoSubmitLogin = "idp-auto-submit-login";
@ -149,6 +150,7 @@ public static class FeatureFlagKeys
public const string LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split"; public const string LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split";
public const string GeneratorToolsModernization = "generator-tools-modernization"; public const string GeneratorToolsModernization = "generator-tools-modernization";
public const string NewDeviceVerification = "new-device-verification"; public const string NewDeviceVerification = "new-device-verification";
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
public static List<string> GetAllKeys() public static List<string> GetAllKeys()
{ {

View File

@ -25,7 +25,7 @@
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.40" /> <PackageReference Include="AWSSDK.SQS" Version="3.7.400.40" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" /> <PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" /> <PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.18.1" /> <PackageReference Include="Azure.Messaging.ServiceBus" Version="7.18.1" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.21.2" /> <PackageReference Include="Azure.Storage.Blobs" Version="12.21.2" />
<PackageReference Include="Azure.Storage.Queues" Version="12.19.1" /> <PackageReference Include="Azure.Storage.Queues" Version="12.19.1" />
@ -35,22 +35,22 @@
<PackageReference Include="Fido2.AspNet" Version="3.0.1" /> <PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" /> <PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="MailKit" Version="4.8.0" /> <PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.45.0" /> <PackageReference Include="Microsoft.Azure.Cosmos" Version="3.45.0" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" /> <PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.1" /> <PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.1" />
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.8" /> <PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="8.0.8" /> <PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="8.0.10" />
<PackageReference Include="Quartz" Version="3.9.0" /> <PackageReference Include="Quartz" Version="3.9.0" />
<PackageReference Include="SendGrid" Version="9.29.3" /> <PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" /> <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" /> <PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" /> <PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<PackageReference Include="Sentry.Serilog" Version="3.41.4" /> <PackageReference Include="Sentry.Serilog" Version="3.41.4" />
<PackageReference Include="Duende.IdentityServer" Version="7.0.6" /> <PackageReference Include="Duende.IdentityServer" Version="7.0.8" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" /> <PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" />
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" /> <PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
@ -58,7 +58,7 @@
<PackageReference Include="Stripe.net" Version="45.14.0" /> <PackageReference Include="Stripe.net" Version="45.14.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" /> <PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.8" /> <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.6.0" /> <PackageReference Include="LaunchDarkly.ServerSdk" Version="8.6.0" />
</ItemGroup> </ItemGroup>

View File

@ -38,6 +38,10 @@ public class Device : ITableObject<Guid>
/// </summary> /// </summary>
public string? EncryptedPrivateKey { get; set; } public string? EncryptedPrivateKey { get; set; }
/// <summary>
/// Whether the device is active for the user.
/// </summary>
public bool Active { get; set; } = true;
public void SetNewId() public void SetNewId()
{ {

View File

@ -0,0 +1,15 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
You have requested to delete your account. This action cannot be completed because your account is owned by an organization.
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
Please contact your organization administrator for additional details.
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,6 @@
{{#>BasicTextLayout}}
You have requested to delete your account. This action cannot be completed because your account is owned by an organization.
Please contact your organization administrator for additional details.
{{/BasicTextLayout}}

View File

@ -7,7 +7,7 @@ public interface IDeviceService
{ {
Task SaveAsync(Device device); Task SaveAsync(Device device);
Task ClearTokenAsync(Device device); Task ClearTokenAsync(Device device);
Task DeleteAsync(Device device); Task DeactivateAsync(Device device);
Task UpdateDevicesTrustAsync(string currentDeviceIdentifier, Task UpdateDevicesTrustAsync(string currentDeviceIdentifier,
Guid currentUserId, Guid currentUserId,
DeviceKeysUpdateRequestModel currentDeviceUpdate, DeviceKeysUpdateRequestModel currentDeviceUpdate,

View File

@ -18,6 +18,7 @@ public interface IMailService
ProductTierType productTier, ProductTierType productTier,
IEnumerable<ProductType> products); IEnumerable<ProductType> products);
Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token); Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token);
Task SendCannotDeleteManagedAccountEmailAsync(string email);
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
Task SendTwoFactorEmailAsync(string email, string token); Task SendTwoFactorEmailAsync(string email, string token);

View File

@ -14,6 +14,17 @@ public interface IStripeAdapter
CustomerBalanceTransactionCreateOptions options); CustomerBalanceTransactionCreateOptions options);
Task<Stripe.Subscription> SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions subscriptionCreateOptions); Task<Stripe.Subscription> SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions subscriptionCreateOptions);
Task<Stripe.Subscription> SubscriptionGetAsync(string id, Stripe.SubscriptionGetOptions options = null); Task<Stripe.Subscription> SubscriptionGetAsync(string id, Stripe.SubscriptionGetOptions options = null);
/// <summary>
/// Retrieves a subscription object for a provider.
/// </summary>
/// <param name="id">The subscription ID.</param>
/// <param name="providerId">The provider ID.</param>
/// <param name="options">Additional options.</param>
/// <returns>The subscription object.</returns>
/// <exception cref="InvalidOperationException">Thrown when the subscription doesn't belong to the provider.</exception>
Task<Stripe.Subscription> ProviderSubscriptionGetAsync(string id, Guid providerId, Stripe.SubscriptionGetOptions options = null);
Task<List<Stripe.Subscription>> SubscriptionListAsync(StripeSubscriptionListOptions subscriptionSearchOptions); Task<List<Stripe.Subscription>> SubscriptionListAsync(StripeSubscriptionListOptions subscriptionSearchOptions);
Task<Stripe.Subscription> SubscriptionUpdateAsync(string id, Stripe.SubscriptionUpdateOptions options = null); Task<Stripe.Subscription> SubscriptionUpdateAsync(string id, Stripe.SubscriptionUpdateOptions options = null);
Task<Stripe.Subscription> SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null); Task<Stripe.Subscription> SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null);

View File

@ -41,9 +41,18 @@ public class DeviceService : IDeviceService
await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString()); await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString());
} }
public async Task DeleteAsync(Device device) public async Task DeactivateAsync(Device device)
{ {
await _deviceRepository.DeleteAsync(device); // already deactivated
if (!device.Active)
{
return;
}
device.Active = false;
device.RevisionDate = DateTime.UtcNow;
await _deviceRepository.UpsertAsync(device);
await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString()); await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString());
} }

View File

@ -112,6 +112,19 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
public async Task SendCannotDeleteManagedAccountEmailAsync(string email)
{
var message = CreateDefaultMessage("Delete Your Account", email);
var model = new CannotDeleteManagedAccountViewModel
{
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
};
await AddMessageContentAsync(message, "AdminConsole.CannotDeleteManagedAccount", model);
message.Category = "CannotDeleteManagedAccount";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail) public async Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail)
{ {
var message = CreateDefaultMessage("Your Email Change", toEmail); var message = CreateDefaultMessage("Your Email Change", toEmail);

View File

@ -79,6 +79,20 @@ public class StripeAdapter : IStripeAdapter
return _subscriptionService.GetAsync(id, options); return _subscriptionService.GetAsync(id, options);
} }
public async Task<Subscription> ProviderSubscriptionGetAsync(
string id,
Guid providerId,
SubscriptionGetOptions options = null)
{
var subscription = await _subscriptionService.GetAsync(id, options);
if (subscription.Metadata.TryGetValue("providerId", out var value) && value == providerId.ToString())
{
return subscription;
}
throw new InvalidOperationException("Subscription does not belong to the provider.");
}
public Task<Stripe.Subscription> SubscriptionUpdateAsync(string id, public Task<Stripe.Subscription> SubscriptionUpdateAsync(string id,
Stripe.SubscriptionUpdateOptions options = null) Stripe.SubscriptionUpdateOptions options = null)
{ {

View File

@ -792,19 +792,16 @@ public class StripePaymentService : IPaymentService
var daysUntilDue = sub.DaysUntilDue; var daysUntilDue = sub.DaysUntilDue;
var chargeNow = collectionMethod == "charge_automatically"; var chargeNow = collectionMethod == "charge_automatically";
var updatedItemOptions = subscriptionUpdate.UpgradeItemsOptions(sub); var updatedItemOptions = subscriptionUpdate.UpgradeItemsOptions(sub);
var isPm5864DollarThresholdEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5864DollarThreshold);
var isAnnualPlan = sub?.Items?.Data.FirstOrDefault()?.Plan?.Interval == "year"; var isAnnualPlan = sub?.Items?.Data.FirstOrDefault()?.Plan?.Interval == "year";
var subUpdateOptions = new SubscriptionUpdateOptions var subUpdateOptions = new SubscriptionUpdateOptions
{ {
Items = updatedItemOptions, Items = updatedItemOptions,
ProrationBehavior = !isPm5864DollarThresholdEnabled || invoiceNow ProrationBehavior = invoiceNow ? Constants.AlwaysInvoice : Constants.CreateProrations,
? Constants.AlwaysInvoice
: Constants.CreateProrations,
DaysUntilDue = daysUntilDue ?? 1, DaysUntilDue = daysUntilDue ?? 1,
CollectionMethod = "send_invoice" CollectionMethod = "send_invoice"
}; };
if (!invoiceNow && isAnnualPlan && isPm5864DollarThresholdEnabled && sub.Status.Trim() != "trialing") if (!invoiceNow && isAnnualPlan && sub.Status.Trim() != "trialing")
{ {
subUpdateOptions.PendingInvoiceItemInterval = subUpdateOptions.PendingInvoiceItemInterval =
new SubscriptionPendingInvoiceItemIntervalOptions { Interval = "month" }; new SubscriptionPendingInvoiceItemIntervalOptions { Interval = "month" };
@ -838,7 +835,7 @@ public class StripePaymentService : IPaymentService
{ {
try try
{ {
if (!isPm5864DollarThresholdEnabled && !invoiceNow) if (invoiceNow)
{ {
if (chargeNow) if (chargeNow)
{ {

View File

@ -297,6 +297,12 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return; return;
} }
if (await IsManagedByAnyOrganizationAsync(user.Id))
{
await _mailService.SendCannotDeleteManagedAccountEmailAsync(user.Email);
return;
}
var token = await base.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "DeleteAccount"); var token = await base.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "DeleteAccount");
await _mailService.SendVerifyDeleteEmailAsync(user.Email, user.Id, token); await _mailService.SendVerifyDeleteEmailAsync(user.Email, user.Id, token);
} }

View File

@ -94,6 +94,11 @@ public class NoopMailService : IMailService
return Task.FromResult(0); return Task.FromResult(0);
} }
public Task SendCannotDeleteManagedAccountEmailAsync(string email)
{
return Task.FromResult(0);
}
public Task SendPasswordlessSignInAsync(string returnUrl, string token, string email) public Task SendPasswordlessSignInAsync(string returnUrl, string token, string email)
{ {
return Task.FromResult(0); return Task.FromResult(0);

View File

@ -0,0 +1,16 @@
using Bit.Core.Entities;
using Bit.Core.Utilities;
public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable
{
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public string Uri { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public void SetNewId()
{
Id = CoreHelpers.GenerateComb();
}
}

View File

@ -8,4 +8,5 @@ public enum CipherType : byte
SecureNote = 2, SecureNote = 2,
Card = 3, Card = 3,
Identity = 4, Identity = 4,
SSHKey = 5,
} }

View File

@ -0,0 +1,10 @@
namespace Bit.Core.Vault.Models.Data;
public class CipherSSHKeyData : CipherData
{
public CipherSSHKeyData() { }
public string PrivateKey { get; set; }
public string PublicKey { get; set; }
public string KeyFingerprint { get; set; }
}

View File

@ -21,6 +21,10 @@ public class DeviceEntityTypeConfiguration : IEntityTypeConfiguration<Device>
.HasIndex(d => d.Identifier) .HasIndex(d => d.Identifier)
.IsClustered(false); .IsClustered(false);
builder.Property(c => c.Active)
.ValueGeneratedNever()
.HasDefaultValue(true);
builder.ToTable(nameof(Device)); builder.ToTable(nameof(Device));
} }
} }

View File

@ -7,7 +7,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="6.8.1" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="6.9.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -9,7 +9,8 @@
@RevisionDate DATETIME2(7), @RevisionDate DATETIME2(7),
@EncryptedUserKey VARCHAR(MAX) = NULL, @EncryptedUserKey VARCHAR(MAX) = NULL,
@EncryptedPublicKey VARCHAR(MAX) = NULL, @EncryptedPublicKey VARCHAR(MAX) = NULL,
@EncryptedPrivateKey VARCHAR(MAX) = NULL @EncryptedPrivateKey VARCHAR(MAX) = NULL,
@Active BIT = 1
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET NOCOUNT ON
@ -26,7 +27,8 @@ BEGIN
[RevisionDate], [RevisionDate],
[EncryptedUserKey], [EncryptedUserKey],
[EncryptedPublicKey], [EncryptedPublicKey],
[EncryptedPrivateKey] [EncryptedPrivateKey],
[Active]
) )
VALUES VALUES
( (
@ -40,6 +42,7 @@ BEGIN
@RevisionDate, @RevisionDate,
@EncryptedUserKey, @EncryptedUserKey,
@EncryptedPublicKey, @EncryptedPublicKey,
@EncryptedPrivateKey @EncryptedPrivateKey,
@Active
) )
END END

View File

@ -1,12 +0,0 @@
CREATE PROCEDURE [dbo].[Device_DeleteById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
DELETE
FROM
[dbo].[Device]
WHERE
[Id] = @Id
END

View File

@ -9,7 +9,8 @@
@RevisionDate DATETIME2(7), @RevisionDate DATETIME2(7),
@EncryptedUserKey VARCHAR(MAX) = NULL, @EncryptedUserKey VARCHAR(MAX) = NULL,
@EncryptedPublicKey VARCHAR(MAX) = NULL, @EncryptedPublicKey VARCHAR(MAX) = NULL,
@EncryptedPrivateKey VARCHAR(MAX) = NULL @EncryptedPrivateKey VARCHAR(MAX) = NULL,
@Active BIT = 1
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET NOCOUNT ON
@ -26,7 +27,8 @@ BEGIN
[RevisionDate] = @RevisionDate, [RevisionDate] = @RevisionDate,
[EncryptedUserKey] = @EncryptedUserKey, [EncryptedUserKey] = @EncryptedUserKey,
[EncryptedPublicKey] = @EncryptedPublicKey, [EncryptedPublicKey] = @EncryptedPublicKey,
[EncryptedPrivateKey] = @EncryptedPrivateKey [EncryptedPrivateKey] = @EncryptedPrivateKey,
[Active] = @Active
WHERE WHERE
[Id] = @Id [Id] = @Id
END END

View File

@ -0,0 +1,10 @@
CREATE PROCEDURE dbo.PasswordHealthReportApplication_Create
@Id UNIQUEIDENTIFIER OUTPUT,
@OrganizationId UNIQUEIDENTIFIER,
@Uri nvarchar(max),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7)
AS
SET NOCOUNT ON;
INSERT INTO dbo.PasswordHealthReportApplication ( Id, OrganizationId, Uri, CreationDate, RevisionDate )
VALUES ( @Id, @OrganizationId, @Uri, @CreationDate, @RevisionDate )

View File

@ -0,0 +1,10 @@
CREATE PROCEDURE dbo.PasswordHealthReportApplication_DeleteById
@Id UNIQUEIDENTIFIER
AS
SET NOCOUNT ON;
IF @Id IS NULL
THROW 50000, 'Id cannot be null', 1;
DELETE FROM [dbo].[PasswordHealthReportApplication]
WHERE [Id] = @Id

View File

@ -0,0 +1,16 @@
CREATE PROCEDURE dbo.PasswordHealthReportApplication_ReadById
@Id UNIQUEIDENTIFIER
AS
SET NOCOUNT ON;
IF @Id IS NULL
THROW 50000, 'Id cannot be null', 1;
SELECT
Id,
OrganizationId,
Uri,
CreationDate,
RevisionDate
FROM [dbo].[PasswordHealthReportApplicationView]
WHERE Id = @Id;

View File

@ -0,0 +1,16 @@
CREATE PROCEDURE dbo.PasswordHealthReportApplication_ReadByOrganizationId
@OrganizationId UNIQUEIDENTIFIER
AS
SET NOCOUNT ON;
IF @OrganizationId IS NULL
THROW 50000, 'OrganizationId cannot be null', 1;
SELECT
Id,
OrganizationId,
Uri,
CreationDate,
RevisionDate
FROM [dbo].[PasswordHealthReportApplicationView]
WHERE OrganizationId = @OrganizationId;

View File

@ -0,0 +1,13 @@
CREATE PROC dbo.PasswordHealthReportApplication_Update
@Id UNIQUEIDENTIFIER OUTPUT,
@OrganizationId UNIQUEIDENTIFIER,
@Uri nvarchar(max),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7)
AS
SET NOCOUNT ON;
UPDATE dbo.PasswordHealthReportApplication
SET OrganizationId = @OrganizationId,
Uri = @Uri,
RevisionDate = @RevisionDate
WHERE Id = @Id

View File

@ -10,16 +10,15 @@
[EncryptedUserKey] VARCHAR (MAX) NULL, [EncryptedUserKey] VARCHAR (MAX) NULL,
[EncryptedPublicKey] VARCHAR (MAX) NULL, [EncryptedPublicKey] VARCHAR (MAX) NULL,
[EncryptedPrivateKey] VARCHAR (MAX) NULL, [EncryptedPrivateKey] VARCHAR (MAX) NULL,
[Active] BIT NOT NULL CONSTRAINT [DF_Device_Active] DEFAULT (1),
CONSTRAINT [PK_Device] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [PK_Device] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_Device_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) CONSTRAINT [FK_Device_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id])
); );
GO GO
CREATE UNIQUE NONCLUSTERED INDEX [UX_Device_UserId_Identifier] CREATE UNIQUE NONCLUSTERED INDEX [UX_Device_UserId_Identifier]
ON [dbo].[Device]([UserId] ASC, [Identifier] ASC); ON [dbo].[Device]([UserId] ASC, [Identifier] ASC);
GO GO
CREATE NONCLUSTERED INDEX [IX_Device_Identifier] CREATE NONCLUSTERED INDEX [IX_Device_Identifier]
ON [dbo].[Device]([Identifier] ASC); ON [dbo].[Device]([Identifier] ASC);

View File

@ -0,0 +1,15 @@
CREATE TABLE [dbo].[PasswordHealthReportApplication]
(
Id UNIQUEIDENTIFIER NOT NULL,
OrganizationId UNIQUEIDENTIFIER NOT NULL,
Uri nvarchar(max),
CreationDate DATETIME2(7) NOT NULL,
RevisionDate DATETIME2(7) NOT NULL,
CONSTRAINT [PK_PasswordHealthReportApplication] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_PasswordHealthReportApplication_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
);
GO
CREATE NONCLUSTERED INDEX [IX_PasswordHealthReportApplication_OrganizationId]
ON [dbo].[PasswordHealthReportApplication] (OrganizationId);
GO

View File

@ -0,0 +1,2 @@
CREATE VIEW [dbo].[PasswordHealthReportApplicationView] AS
SELECT * FROM [dbo].[PasswordHealthReportApplication]

View File

@ -2,6 +2,7 @@
using Bit.Admin.Models; using Bit.Admin.Models;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Vault.Entities;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
namespace Admin.Test.Models; namespace Admin.Test.Models;
@ -79,7 +80,7 @@ public class UserViewModelTests
{ {
var lookup = new List<(Guid, bool)> { (user.Id, true) }; var lookup = new List<(Guid, bool)> { (user.Id, true) };
var actual = UserViewModel.MapViewModel(user, lookup); var actual = UserViewModel.MapViewModel(user, lookup, false);
Assert.True(actual.TwoFactorEnabled); Assert.True(actual.TwoFactorEnabled);
} }
@ -90,7 +91,7 @@ public class UserViewModelTests
{ {
var lookup = new List<(Guid, bool)> { (user.Id, false) }; var lookup = new List<(Guid, bool)> { (user.Id, false) };
var actual = UserViewModel.MapViewModel(user, lookup); var actual = UserViewModel.MapViewModel(user, lookup, false);
Assert.False(actual.TwoFactorEnabled); Assert.False(actual.TwoFactorEnabled);
} }
@ -101,8 +102,44 @@ public class UserViewModelTests
{ {
var lookup = new List<(Guid, bool)> { (Guid.NewGuid(), true) }; var lookup = new List<(Guid, bool)> { (Guid.NewGuid(), true) };
var actual = UserViewModel.MapViewModel(user, lookup); var actual = UserViewModel.MapViewModel(user, lookup, false);
Assert.False(actual.TwoFactorEnabled); Assert.False(actual.TwoFactorEnabled);
} }
[Theory]
[BitAutoData]
public void MapUserViewModel_WithVerifiedDomain_ReturnsUserViewModel(User user)
{
var verifiedDomain = true;
var actual = UserViewModel.MapViewModel(user, true, Array.Empty<Cipher>(), verifiedDomain);
Assert.True(actual.DomainVerified);
}
[Theory]
[BitAutoData]
public void MapUserViewModel_WithoutVerifiedDomain_ReturnsUserViewModel(User user)
{
var verifiedDomain = false;
var actual = UserViewModel.MapViewModel(user, true, Array.Empty<Cipher>(), verifiedDomain);
Assert.False(actual.DomainVerified);
}
[Theory]
[BitAutoData]
public void MapUserViewModel_WithNullVerifiedDomain_ReturnsUserViewModel(User user)
{
var actual = UserViewModel.MapViewModel(user, true, Array.Empty<Cipher>(), null);
Assert.Null(actual.DomainVerified);
}
} }

View File

@ -1,6 +1,15 @@
using System.Net.Http.Headers; using System.Net;
using System.Net.Http.Headers;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Services;
using NSubstitute;
using Xunit; using Xunit;
namespace Bit.Api.IntegrationTest.Controllers; namespace Bit.Api.IntegrationTest.Controllers;
@ -35,4 +44,82 @@ public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>
Assert.Null(content.PrivateKey); Assert.Null(content.PrivateKey);
Assert.NotNull(content.SecurityStamp); Assert.NotNull(content.SecurityStamp);
} }
[Fact]
public async Task PostEmailToken_WhenAccountDeprovisioningEnabled_WithManagedAccount_ThrowsBadRequest()
{
var email = await SetupOrganizationManagedAccount();
var tokens = await _factory.LoginAsync(email);
var client = _factory.CreateClient();
var model = new EmailTokenRequestModel
{
NewEmail = $"{Guid.NewGuid()}@example.com",
MasterPasswordHash = "master_password_hash"
};
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/email-token")
{
Content = JsonContent.Create(model)
};
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token);
var response = await client.SendAsync(message);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Cannot change emails for accounts owned by an organization", content);
}
[Fact]
public async Task PostEmail_WhenAccountDeprovisioningEnabled_WithManagedAccount_ThrowsBadRequest()
{
var email = await SetupOrganizationManagedAccount();
var tokens = await _factory.LoginAsync(email);
var client = _factory.CreateClient();
var model = new EmailRequestModel
{
NewEmail = $"{Guid.NewGuid()}@example.com",
MasterPasswordHash = "master_password_hash",
NewMasterPasswordHash = "master_password_hash",
Token = "validtoken",
Key = "key"
};
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/email")
{
Content = JsonContent.Create(model)
};
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token);
var response = await client.SendAsync(message);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Cannot change emails for accounts owned by an organization", content);
}
private async Task<string> SetupOrganizationManagedAccount()
{
_factory.SubstituteService<IFeatureService>(featureService =>
featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true));
// Create the owner account
var ownerEmail = $"{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(ownerEmail);
// Create the organization
var (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023,
ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
// Create a new organization member
var (email, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id,
OrganizationUserType.Custom, new Permissions { AccessReports = true, ManageScim = true });
// Add a verified domain
await OrganizationTestHelpers.CreateVerifiedDomainAsync(_factory, _organization.Id, "bitwarden.com");
return email;
}
} }

View File

@ -105,4 +105,22 @@ public static class OrganizationTestHelpers
return (email, organizationUser); return (email, organizationUser);
} }
/// <summary>
/// Creates a VerifiedDomain for the specified organization.
/// </summary>
public static async Task CreateVerifiedDomainAsync(ApiApplicationFactory factory, Guid organizationId, string domain)
{
var organizationDomainRepository = factory.GetService<IOrganizationDomainRepository>();
var verifiedDomain = new OrganizationDomain
{
OrganizationId = organizationId,
DomainName = domain,
Txt = "btw+test18383838383"
};
verifiedDomain.SetVerifiedDate();
await organizationDomainRepository.CreateAsync(verifiedDomain);
}
} }

View File

@ -1,7 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using Bit.Api.AdminConsole.Controllers; using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
@ -273,17 +272,12 @@ public class OrganizationUsersControllerTests
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task DeleteAccount_WhenUserCanManageUsers_Success( public async Task DeleteAccount_WhenUserCanManageUsers_Success(
Guid orgId, Guid orgId, Guid id, User currentUser, SutProvider<OrganizationUsersController> sutProvider)
Guid id,
SecretVerificationRequestModel model,
User currentUser,
SutProvider<OrganizationUsersController> sutProvider)
{ {
sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true); sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser); sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser);
sutProvider.GetDependency<IUserService>().VerifySecretAsync(currentUser, model.Secret).Returns(true);
await sutProvider.Sut.DeleteAccount(orgId, id, model); await sutProvider.Sut.DeleteAccount(orgId, id);
await sutProvider.GetDependency<IDeleteManagedOrganizationUserAccountCommand>() await sutProvider.GetDependency<IDeleteManagedOrganizationUserAccountCommand>()
.Received(1) .Received(1)
@ -293,60 +287,34 @@ public class OrganizationUsersControllerTests
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task DeleteAccount_WhenUserCannotManageUsers_ThrowsNotFoundException( public async Task DeleteAccount_WhenUserCannotManageUsers_ThrowsNotFoundException(
Guid orgId, Guid orgId, Guid id, SutProvider<OrganizationUsersController> sutProvider)
Guid id,
SecretVerificationRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{ {
sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(false); sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(false);
await Assert.ThrowsAsync<NotFoundException>(() => await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.DeleteAccount(orgId, id, model)); sutProvider.Sut.DeleteAccount(orgId, id));
} }
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task DeleteAccount_WhenCurrentUserNotFound_ThrowsUnauthorizedAccessException( public async Task DeleteAccount_WhenCurrentUserNotFound_ThrowsUnauthorizedAccessException(
Guid orgId, Guid orgId, Guid id, SutProvider<OrganizationUsersController> sutProvider)
Guid id,
SecretVerificationRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{ {
sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true); sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs((User)null); sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs((User)null);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
sutProvider.Sut.DeleteAccount(orgId, id, model)); sutProvider.Sut.DeleteAccount(orgId, id));
}
[Theory]
[BitAutoData]
public async Task DeleteAccount_WhenSecretVerificationFails_ThrowsBadRequestException(
Guid orgId,
Guid id,
SecretVerificationRequestModel model,
User currentUser,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser);
sutProvider.GetDependency<IUserService>().VerifySecretAsync(currentUser, model.Secret).Returns(false);
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.DeleteAccount(orgId, id, model));
} }
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task BulkDeleteAccount_WhenUserCanManageUsers_Success( public async Task BulkDeleteAccount_WhenUserCanManageUsers_Success(
Guid orgId, Guid orgId, OrganizationUserBulkRequestModel model, User currentUser,
SecureOrganizationUserBulkRequestModel model, List<(Guid, string)> deleteResults, SutProvider<OrganizationUsersController> sutProvider)
User currentUser,
List<(Guid, string)> deleteResults,
SutProvider<OrganizationUsersController> sutProvider)
{ {
sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true); sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser); sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser);
sutProvider.GetDependency<IUserService>().VerifySecretAsync(currentUser, model.Secret).Returns(true);
sutProvider.GetDependency<IDeleteManagedOrganizationUserAccountCommand>() sutProvider.GetDependency<IDeleteManagedOrganizationUserAccountCommand>()
.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id) .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id)
.Returns(deleteResults); .Returns(deleteResults);
@ -363,9 +331,7 @@ public class OrganizationUsersControllerTests
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task BulkDeleteAccount_WhenUserCannotManageUsers_ThrowsNotFoundException( public async Task BulkDeleteAccount_WhenUserCannotManageUsers_ThrowsNotFoundException(
Guid orgId, Guid orgId, OrganizationUserBulkRequestModel model, SutProvider<OrganizationUsersController> sutProvider)
SecureOrganizationUserBulkRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{ {
sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(false); sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(false);
@ -376,9 +342,7 @@ public class OrganizationUsersControllerTests
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task BulkDeleteAccount_WhenCurrentUserNotFound_ThrowsUnauthorizedAccessException( public async Task BulkDeleteAccount_WhenCurrentUserNotFound_ThrowsUnauthorizedAccessException(
Guid orgId, Guid orgId, OrganizationUserBulkRequestModel model, SutProvider<OrganizationUsersController> sutProvider)
SecureOrganizationUserBulkRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{ {
sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true); sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs((User)null); sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs((User)null);
@ -387,21 +351,6 @@ public class OrganizationUsersControllerTests
sutProvider.Sut.BulkDeleteAccount(orgId, model)); sutProvider.Sut.BulkDeleteAccount(orgId, model));
} }
[Theory]
[BitAutoData]
public async Task BulkDeleteAccount_WhenSecretVerificationFails_ThrowsBadRequestException(
Guid orgId,
SecureOrganizationUserBulkRequestModel model,
User currentUser,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().ManageUsers(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser);
sutProvider.GetDependency<IUserService>().VerifySecretAsync(currentUser, model.Secret).Returns(false);
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.BulkDeleteAccount(orgId, model));
}
private void GetMany_Setup(OrganizationAbility organizationAbility, private void GetMany_Setup(OrganizationAbility organizationAbility,
ICollection<OrganizationUserUserDetails> organizationUsers, ICollection<OrganizationUserUserDetails> organizationUsers,
SutProvider<OrganizationUsersController> sutProvider) SutProvider<OrganizationUsersController> sutProvider)

View File

@ -7,6 +7,7 @@ using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Api.Auth.Validators; using Bit.Api.Auth.Validators;
using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Request;
using Bit.Core;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
@ -143,6 +144,21 @@ public class AccountsControllerTests : IDisposable
await _userService.Received(1).InitiateEmailChangeAsync(user, newEmail); await _userService.Received(1).InitiateEmailChangeAsync(user, newEmail);
} }
[Fact]
public async Task PostEmailToken_WithAccountDeprovisioningEnabled_WhenUserIsNotManagedByAnOrganization_ShouldInitiateEmailChange()
{
var user = GenerateExampleUser();
ConfigureUserServiceToReturnValidPrincipalFor(user);
ConfigureUserServiceToAcceptPasswordFor(user);
_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
_userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false);
var newEmail = "example@user.com";
await _sut.PostEmailToken(new EmailTokenRequestModel { NewEmail = newEmail });
await _userService.Received(1).InitiateEmailChangeAsync(user, newEmail);
}
[Fact] [Fact]
public async Task PostEmailToken_WhenNotAuthorized_ShouldThrowUnauthorizedAccessException() public async Task PostEmailToken_WhenNotAuthorized_ShouldThrowUnauthorizedAccessException()
{ {
@ -165,6 +181,22 @@ public class AccountsControllerTests : IDisposable
); );
} }
[Fact]
public async Task PostEmailToken_WithAccountDeprovisioningEnabled_WhenUserIsManagedByAnOrganization_ShouldThrowBadRequestException()
{
var user = GenerateExampleUser();
ConfigureUserServiceToReturnValidPrincipalFor(user);
ConfigureUserServiceToAcceptPasswordFor(user);
_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
_userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(true);
var result = await Assert.ThrowsAsync<BadRequestException>(
() => _sut.PostEmailToken(new EmailTokenRequestModel())
);
Assert.Equal("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.", result.Message);
}
[Fact] [Fact]
public async Task PostEmail_ShouldChangeUserEmail() public async Task PostEmail_ShouldChangeUserEmail()
{ {
@ -178,6 +210,21 @@ public class AccountsControllerTests : IDisposable
await _userService.Received(1).ChangeEmailAsync(user, default, default, default, default, default); await _userService.Received(1).ChangeEmailAsync(user, default, default, default, default, default);
} }
[Fact]
public async Task PostEmail_WithAccountDeprovisioningEnabled_WhenUserIsNotManagedByAnOrganization_ShouldChangeUserEmail()
{
var user = GenerateExampleUser();
ConfigureUserServiceToReturnValidPrincipalFor(user);
_userService.ChangeEmailAsync(user, default, default, default, default, default)
.Returns(Task.FromResult(IdentityResult.Success));
_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
_userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false);
await _sut.PostEmail(new EmailRequestModel());
await _userService.Received(1).ChangeEmailAsync(user, default, default, default, default, default);
}
[Fact] [Fact]
public async Task PostEmail_WhenNotAuthorized_ShouldThrownUnauthorizedAccessException() public async Task PostEmail_WhenNotAuthorized_ShouldThrownUnauthorizedAccessException()
{ {
@ -201,6 +248,21 @@ public class AccountsControllerTests : IDisposable
); );
} }
[Fact]
public async Task PostEmail_WithAccountDeprovisioningEnabled_WhenUserIsManagedByAnOrganization_ShouldThrowBadRequestException()
{
var user = GenerateExampleUser();
ConfigureUserServiceToReturnValidPrincipalFor(user);
_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
_userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(true);
var result = await Assert.ThrowsAsync<BadRequestException>(
() => _sut.PostEmail(new EmailRequestModel())
);
Assert.Equal("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.", result.Message);
}
[Fact] [Fact]
public async Task PostVerifyEmail_ShouldSendEmailVerification() public async Task PostVerifyEmail_ShouldSendEmailVerification()
{ {
@ -472,6 +534,34 @@ public class AccountsControllerTests : IDisposable
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostSetPasswordAsync(model)); await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostSetPasswordAsync(model));
} }
[Fact]
public async Task Delete_WhenAccountDeprovisioningIsEnabled_WithUserManagedByAnOrganization_ThrowsBadRequestException()
{
var user = GenerateExampleUser();
ConfigureUserServiceToReturnValidPrincipalFor(user);
ConfigureUserServiceToAcceptPasswordFor(user);
_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
_userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(true);
var result = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Delete(new SecretVerificationRequestModel()));
Assert.Equal("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.", result.Message);
}
[Fact]
public async Task Delete_WhenAccountDeprovisioningIsEnabled_WithUserNotManagedByAnOrganization_ShouldSucceed()
{
var user = GenerateExampleUser();
ConfigureUserServiceToReturnValidPrincipalFor(user);
ConfigureUserServiceToAcceptPasswordFor(user);
_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true);
_userService.IsManagedByAnyOrganizationAsync(user.Id).Returns(false);
_userService.DeleteAsync(user).Returns(IdentityResult.Success);
await _sut.Delete(new SecretVerificationRequestModel());
await _userService.Received(1).DeleteAsync(user);
}
// Below are helper functions that currently belong to this // Below are helper functions that currently belong to this
// test class, but ultimately may need to be split out into // test class, but ultimately may need to be split out into

View File

@ -37,7 +37,7 @@ public class OrganizationBillingControllerTests
Guid organizationId, Guid organizationId,
SutProvider<OrganizationBillingController> sutProvider) SutProvider<OrganizationBillingController> sutProvider)
{ {
sutProvider.GetDependency<ICurrentContext>().AccessMembersTab(organizationId).Returns(true); sutProvider.GetDependency<ICurrentContext>().OrganizationUser(organizationId).Returns(true);
sutProvider.GetDependency<IOrganizationBillingService>().GetMetadata(organizationId).Returns((OrganizationMetadata)null); sutProvider.GetDependency<IOrganizationBillingService>().GetMetadata(organizationId).Returns((OrganizationMetadata)null);
var result = await sutProvider.Sut.GetMetadataAsync(organizationId); var result = await sutProvider.Sut.GetMetadataAsync(organizationId);
@ -50,18 +50,20 @@ public class OrganizationBillingControllerTests
Guid organizationId, Guid organizationId,
SutProvider<OrganizationBillingController> sutProvider) SutProvider<OrganizationBillingController> sutProvider)
{ {
sutProvider.GetDependency<ICurrentContext>().AccessMembersTab(organizationId).Returns(true); sutProvider.GetDependency<ICurrentContext>().OrganizationUser(organizationId).Returns(true);
sutProvider.GetDependency<IOrganizationBillingService>().GetMetadata(organizationId) sutProvider.GetDependency<IOrganizationBillingService>().GetMetadata(organizationId)
.Returns(new OrganizationMetadata(true, true)); .Returns(new OrganizationMetadata(true, true, true, true));
var result = await sutProvider.Sut.GetMetadataAsync(organizationId); var result = await sutProvider.Sut.GetMetadataAsync(organizationId);
Assert.IsType<Ok<OrganizationMetadataResponse>>(result); Assert.IsType<Ok<OrganizationMetadataResponse>>(result);
var organizationMetadataResponse = ((Ok<OrganizationMetadataResponse>)result).Value; var response = ((Ok<OrganizationMetadataResponse>)result).Value;
Assert.True(organizationMetadataResponse.IsEligibleForSelfHost); Assert.True(response.IsEligibleForSelfHost);
Assert.True(organizationMetadataResponse.IsOnSecretsManagerStandalone); Assert.True(response.IsManaged);
Assert.True(response.IsOnSecretsManagerStandalone);
Assert.True(response.IsSubscriptionUnpaid);
} }
[Theory, BitAutoData] [Theory, BitAutoData]

View File

@ -3,8 +3,10 @@ using System.Text.Json;
using Bit.Api.AdminConsole.Controllers; using Bit.Api.AdminConsole.Controllers;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Api.Response;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -132,4 +134,71 @@ public class PoliciesControllerTests
// Act & Assert // Act & Assert
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.GetMasterPasswordPolicy(orgId)); await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.GetMasterPasswordPolicy(orgId));
} }
[Theory]
[BitAutoData]
public async Task Get_WhenUserCanManagePolicies_WithExistingType_ReturnsExistingPolicy(
SutProvider<PoliciesController> sutProvider, Guid orgId, Policy policy, int type)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>()
.ManagePolicies(orgId)
.Returns(true);
policy.Type = (PolicyType)type;
policy.Enabled = true;
policy.Data = null;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type)
.Returns(policy);
// Act
var result = await sutProvider.Sut.Get(orgId, type);
// Assert
Assert.IsType<PolicyResponseModel>(result);
Assert.Equal(policy.Id, result.Id);
Assert.Equal(policy.Type, result.Type);
Assert.Equal(policy.Enabled, result.Enabled);
Assert.Equal(policy.OrganizationId, result.OrganizationId);
}
[Theory]
[BitAutoData]
public async Task Get_WhenUserCanManagePolicies_WithNonExistingType_ReturnsDefaultPolicy(
SutProvider<PoliciesController> sutProvider, Guid orgId, int type)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>()
.ManagePolicies(orgId)
.Returns(true);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type)
.Returns((Policy)null);
// Act
var result = await sutProvider.Sut.Get(orgId, type);
// Assert
Assert.IsType<PolicyResponseModel>(result);
Assert.Equal(result.Type, (PolicyType)type);
Assert.False(result.Enabled);
}
[Theory]
[BitAutoData]
public async Task Get_WhenUserCannotManagePolicies_ThrowsNotFoundException(
SutProvider<PoliciesController> sutProvider, Guid orgId, int type)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>()
.ManagePolicies(orgId)
.Returns(false);
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.Get(orgId, type));
}
} }

View File

@ -10,7 +10,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" /> <PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" /> <PackageReference Include="xunit" Version="$(XUnitVersion)" />

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@ -8,10 +8,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.9.1" /> <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" /> <PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)"> <PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">

View File

@ -5,7 +5,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
</ItemGroup> </ItemGroup>

View File

@ -0,0 +1,13 @@
SET DEADLOCK_PRIORITY HIGH
GO
UPDATE
[dbo].[Organization]
SET
[UseScim] = 1
WHERE
[PlanType] IN (
17, -- Teams (Monthly)
18 -- Teams (Annually)
)
SET DEADLOCK_PRIORITY NORMAL
GO

View File

@ -0,0 +1,103 @@
IF OBJECT_ID('dbo.PasswordHealthReportApplication') IS NULL
BEGIN
CREATE TABLE [dbo].[PasswordHealthReportApplication]
(
Id UNIQUEIDENTIFIER NOT NULL,
OrganizationId UNIQUEIDENTIFIER NOT NULL,
Uri nvarchar(max),
CreationDate DATETIME2(7) NOT NULL,
RevisionDate DATETIME2(7) NOT NULL,
CONSTRAINT [PK_PasswordHealthReportApplication] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_PasswordHealthReportApplication_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
);
CREATE NONCLUSTERED INDEX [IX_PasswordHealthReportApplication_OrganizationId]
ON [dbo].[PasswordHealthReportApplication] (OrganizationId);
END
GO
IF OBJECT_ID('dbo.PasswordHealthReportApplicationView') IS NOT NULL
BEGIN
DROP VIEW [dbo].[PasswordHealthReportApplicationView]
END
GO
CREATE VIEW [dbo].[PasswordHealthReportApplicationView] AS
SELECT * FROM [dbo].[PasswordHealthReportApplication]
GO
CREATE OR ALTER PROC dbo.PasswordHealthReportApplication_Create
@Id UNIQUEIDENTIFIER OUTPUT,
@OrganizationId UNIQUEIDENTIFIER,
@Uri nvarchar(max),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7)
AS
SET NOCOUNT ON;
INSERT INTO dbo.PasswordHealthReportApplication ( Id, OrganizationId, Uri, CreationDate, RevisionDate )
VALUES ( @Id, @OrganizationId, @Uri, @CreationDate, @RevisionDate )
GO
CREATE OR ALTER PROC dbo.PasswordHealthReportApplication_ReadByOrganizationId
@OrganizationId UNIQUEIDENTIFIER
AS
SET NOCOUNT ON;
IF @OrganizationId IS NULL
THROW 50000, 'OrganizationId cannot be null', 1;
SELECT
Id,
OrganizationId,
Uri,
CreationDate,
RevisionDate
FROM [dbo].[PasswordHealthReportApplicationView]
WHERE OrganizationId = @OrganizationId;
GO
CREATE OR ALTER PROC dbo.PasswordHealthReportApplication_ReadById
@Id UNIQUEIDENTIFIER
AS
SET NOCOUNT ON;
IF @Id IS NULL
THROW 50000, 'Id cannot be null', 1;
SELECT
Id,
OrganizationId,
Uri,
CreationDate,
RevisionDate
FROM [dbo].[PasswordHealthReportApplicationView]
WHERE Id = @Id;
GO
CREATE OR ALTER PROC dbo.PasswordHealthReportApplication_Update
@Id UNIQUEIDENTIFIER OUTPUT,
@OrganizationId UNIQUEIDENTIFIER,
@Uri nvarchar(max),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7)
AS
SET NOCOUNT ON;
UPDATE dbo.PasswordHealthReportApplication
SET OrganizationId = @OrganizationId,
Uri = @Uri,
RevisionDate = @RevisionDate
WHERE Id = @Id
GO
CREATE OR ALTER PROC dbo.PasswordHealthReportApplication_DeleteById
@Id UNIQUEIDENTIFIER
AS
SET NOCOUNT ON;
IF @Id IS NULL
THROW 50000, 'Id cannot be null', 1;
DELETE FROM [dbo].[PasswordHealthReportApplication]
WHERE [Id] = @Id
GO

View File

@ -0,0 +1,118 @@
SET DEADLOCK_PRIORITY HIGH
GO
-- add column
IF COL_LENGTH('[dbo].[Device]', 'Active') IS NULL
BEGIN
ALTER TABLE
[dbo].[Device]
ADD
[Active] BIT NOT NULL CONSTRAINT [DF_Device_Active] DEFAULT (1)
END
GO
-- refresh view
CREATE OR ALTER VIEW [dbo].[DeviceView]
AS
SELECT
*
FROM
[dbo].[Device]
GO
-- drop now-unused proc for deletion
IF OBJECT_ID('[dbo].[Device_DeleteById]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[Device_DeleteById]
END
GO
-- refresh procs
CREATE OR ALTER PROCEDURE [dbo].[Device_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@UserId UNIQUEIDENTIFIER,
@Name NVARCHAR(50),
@Type TINYINT,
@Identifier NVARCHAR(50),
@PushToken NVARCHAR(255),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@EncryptedUserKey VARCHAR(MAX) = NULL,
@EncryptedPublicKey VARCHAR(MAX) = NULL,
@EncryptedPrivateKey VARCHAR(MAX) = NULL,
@Active BIT = 1
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[Device]
(
[Id],
[UserId],
[Name],
[Type],
[Identifier],
[PushToken],
[CreationDate],
[RevisionDate],
[EncryptedUserKey],
[EncryptedPublicKey],
[EncryptedPrivateKey],
[Active]
)
VALUES
(
@Id,
@UserId,
@Name,
@Type,
@Identifier,
@PushToken,
@CreationDate,
@RevisionDate,
@EncryptedUserKey,
@EncryptedPublicKey,
@EncryptedPrivateKey,
@Active
)
END
GO
CREATE OR ALTER PROCEDURE [dbo].[Device_Update]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@Name NVARCHAR(50),
@Type TINYINT,
@Identifier NVARCHAR(50),
@PushToken NVARCHAR(255),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@EncryptedUserKey VARCHAR(MAX) = NULL,
@EncryptedPublicKey VARCHAR(MAX) = NULL,
@EncryptedPrivateKey VARCHAR(MAX) = NULL,
@Active BIT = 1
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[Device]
SET
[UserId] = @UserId,
[Name] = @Name,
[Type] = @Type,
[Identifier] = @Identifier,
[PushToken] = @PushToken,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[EncryptedUserKey] = @EncryptedUserKey,
[EncryptedPublicKey] = @EncryptedPublicKey,
[EncryptedPrivateKey] = @EncryptedPrivateKey,
[Active] = @Active
WHERE
[Id] = @Id
END
GO
SET DEADLOCK_PRIORITY NORMAL
GO

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="DbScripts\**\*.sql" /> <EmbeddedResource Include="DbScripts\**\*.sql" />
@ -7,7 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="dbup-sqlserver" Version="5.0.41" /> <PackageReference Include="dbup-sqlserver" Version="5.0.41" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -11,8 +11,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CommandDotNet" Version="7.0.4" /> <PackageReference Include="CommandDotNet" Version="7.0.4" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class PasswordHealthReportApplication : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PasswordHealthReportApplication",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
OrganizationId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Uri = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreationDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
RevisionDate = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PasswordHealthReportApplication", x => x.Id);
table.ForeignKey(
name: "FK_PasswordHealthReportApplication_Organization_OrganizationId",
column: x => x.OrganizationId,
principalTable: "Organization",
principalColumn: "Id");
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_PasswordHealthReportApplication_OrganizationId",
table: "PasswordHealthReportApplication",
column: "OrganizationId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "PasswordHealthReportApplication");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class DeviceActivation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Active",
table: "Device",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Active",
table: "Device");
}
}

View File

@ -939,6 +939,10 @@ namespace Bit.MySqlMigrations.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<bool>("Active")
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<DateTime>("CreationDate") b.Property<DateTime>("CreationDate")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");

Some files were not shown because too many files have changed in this diff Show More