1
0
mirror of https://github.com/bitwarden/server synced 2025-12-21 18:53:41 +00:00

Merge branch 'main' of github.com:bitwarden/server into arch/seeder-sdk

# Conflicts:
#	.gitignore
#	bitwarden-server.sln
This commit is contained in:
Hinton
2025-10-09 09:46:13 -07:00
1858 changed files with 162611 additions and 13544 deletions

View File

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

24
.github/CODEOWNERS vendored
View File

@@ -4,17 +4,18 @@
#
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
## Docker files have shared ownership ##
**/Dockerfile
**/*.Dockerfile
**/.dockerignore
**/entrypoint.sh
## Docker-related files
**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
**/*.Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
## BRE team owns these workflows ##
.github/workflows/publish.yml @bitwarden/dept-bre
## These are shared workflows ##
.github/workflows/_move_finalization_db_scripts.yml
.github/workflows/_move_edd_db_scripts.yml
.github/workflows/release.yml
# Database Operations for database changes
@@ -33,6 +34,9 @@ util/SqliteMigrations/** @bitwarden/dept-dbops
# Shared util projects
util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
# UIF
src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project
# Auth team
**/Auth @bitwarden/team-auth-dev
bitwarden_license/src/Sso @bitwarden/team-auth-dev
@@ -47,11 +51,7 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
**/Tools @bitwarden/team-tools-dev
# Dirt (Data Insights & Reporting) team
src/Api/Controllers/Dirt @bitwarden/team-data-insights-and-reporting-dev
src/Core/Dirt @bitwarden/team-data-insights-and-reporting-dev
src/Infrastructure.Dapper/Dirt @bitwarden/team-data-insights-and-reporting-dev
test/Api.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev
test/Core.Test/Dirt @bitwarden/team-data-insights-and-reporting-dev
**/Dirt @bitwarden/team-data-insights-and-reporting-dev
# Vault team
**/Vault @bitwarden/team-vault-dev
@@ -93,6 +93,8 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
**/.dockerignore @bitwarden/team-platform-dev
**/Dockerfile @bitwarden/team-platform-dev
**/entrypoint.sh @bitwarden/team-platform-dev
# The PushType enum is expected to be editted by anyone without need for Platform review
src/Core/Platform/Push/PushType.cs
# Multiple owners - DO NOT REMOVE (BRE)
**/packages.lock.json

View File

@@ -1,4 +1,3 @@
name: Bitwarden Unified Bug Report
name: Bitwarden Unified Deployment Bug Report
description: File a bug report
labels: [bug, bw-unified-deploy]

View File

@@ -9,18 +9,6 @@
"nuget",
],
packageRules: [
{
// Group all release-related workflows for GitHub Actions together for BRE.
groupName: "github-action",
matchManagers: ["github-actions"],
matchFileNames: [
".github/workflows/publish.yml",
".github/workflows/release.yml"
],
commitMessagePrefix: "[deps] BRE:",
reviewers: ["team:dept-bre"],
addLabels: ["hold"],
},
{
groupName: "dockerfile minor",
matchManagers: ["dockerfile"],
@@ -35,6 +23,7 @@
groupName: "github-action minor",
matchManagers: ["github-actions"],
matchUpdateTypes: ["minor"],
addLabels: ["hold"],
},
{
// For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates.
@@ -95,7 +84,6 @@
"Serilog.AspNetCore",
"Serilog.Extensions.Logging",
"Serilog.Extensions.Logging.File",
"Serilog.Sinks.AzureCosmosDB",
"Serilog.Sinks.SyslogMessages",
"Stripe.net",
"Swashbuckle.AspNetCore",

View File

@@ -1,5 +1,5 @@
name: _move_finalization_db_scripts
run-name: Move finalization database scripts
name: _move_edd_db_scripts
run-name: Move EDD database scripts
on:
workflow_call:
@@ -12,14 +12,20 @@ jobs:
setup:
name: Setup
runs-on: ubuntu-22.04
permissions:
contents: read
id-token: write
outputs:
migration_filename_prefix: ${{ steps.prefix.outputs.prefix }}
copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }}
copy_edd_scripts: ${{ steps.check-script-existence.outputs.copy_edd_scripts }}
steps:
- name: Log in to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -28,6 +34,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
@@ -37,22 +46,27 @@ jobs:
id: prefix
run: echo "prefix=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Check if any files in DB finalization directory
id: check-finalization-scripts-existence
- name: Check if any files in DB transition or finalization directories
id: check-script-existence
run: |
if [ -f util/Migrator/DbScripts_finalization/* ]; then
echo "copy_finalization_scripts=true" >> $GITHUB_OUTPUT
if [ -f util/Migrator/DbScripts_transition/* -o -f util/Migrator/DbScripts_finalization/* ]; then
echo "copy_edd_scripts=true" >> $GITHUB_OUTPUT
else
echo "copy_finalization_scripts=false" >> $GITHUB_OUTPUT
echo "copy_edd_scripts=false" >> $GITHUB_OUTPUT
fi
move-finalization-db-scripts:
name: Move finalization database scripts
move-scripts:
name: Move scripts
runs-on: ubuntu-22.04
needs: setup
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
permissions:
contents: write
pull-requests: write
id-token: write
actions: read
if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }}
steps:
- name: Checkout
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
@@ -61,23 +75,26 @@ jobs:
id: branch_name
env:
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
run: echo "branch_name=move_finalization_db_scripts_$PREFIX" >> $GITHUB_OUTPUT
run: echo "branch_name=move_edd_db_scripts_$PREFIX" >> $GITHUB_OUTPUT
- name: "Create branch"
env:
BRANCH: ${{ steps.branch_name.outputs.branch_name }}
run: git switch -c $BRANCH
- name: Move DbScripts_finalization
- name: Move scripts and finalization database schema
id: move-files
env:
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
run: |
src_dir="util/Migrator/DbScripts_finalization"
# scripts
moved_files="Migration scripts moved:\n\n"
src_dirs="util/Migrator/DbScripts_transition,util/Migrator/DbScripts_finalization"
dest_dir="util/Migrator/DbScripts"
i=0
moved_files=""
for src_dir in ${src_dirs//,/ }; do
for file in "$src_dir"/*; do
filenumber=$(printf "%02d" $i)
@@ -85,17 +102,43 @@ jobs:
new_filename="${PREFIX}_${filenumber}_${filename}"
dest_file="$dest_dir/$new_filename"
# Replace any finalization references due to the move
sed -i -e 's/dbo_finalization/dbo/g' "$file"
mv "$file" "$dest_file"
moved_files="$moved_files \n $filename -> $new_filename"
i=$((i+1))
done
done
# schema
moved_files="$moved_files\n\nFinalization scripts moved:\n\n"
src_dir="src/Sql/dbo_finalization"
dest_dir="src/Sql/dbo"
# sync finalization schema back to dbo, maintaining structure
rsync -r "$src_dir/" "$dest_dir/"
rm -rf $src_dir/*
# Replace any finalization references due to the move
find ./src/Sql/dbo -name "*.sql" -type f -exec sed -i \
-e 's/\[dbo_finalization\]/[dbo]/g' \
-e 's/dbo_finalization\./dbo./g' {} +
for file in "$src_dir"/**/*; do
moved_files="$moved_files \n $file"
done
echo "moved_files=$moved_files" >> $GITHUB_OUTPUT
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -106,8 +149,11 @@ jobs:
github-gpg-private-key-passphrase,
devops-alerts-slack-webhook-url"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Import GPG keys
uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0
with:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
@@ -121,7 +167,7 @@ jobs:
git config --local user.name "bitwarden-devops-bot"
if [ -n "$(git status --porcelain)" ]; then
git add .
git commit -m "Move DbScripts_finalization to DbScripts" -a
git commit -m "Move EDD database scripts" -a
git push -u origin ${{ steps.branch_name.outputs.branch_name }}
echo "pr_needed=true" >> $GITHUB_OUTPUT
else
@@ -137,16 +183,16 @@ jobs:
BRANCH: ${{ steps.branch_name.outputs.branch_name }}
GH_TOKEN: ${{ github.token }}
MOVED_FILES: ${{ steps.move-files.outputs.moved_files }}
TITLE: "Move finalization database scripts"
TITLE: "Move EDD database scripts"
run: |
PR_URL=$(gh pr create --title "$TITLE" \
--base "main" \
--head "$BRANCH" \
--label "automated pr" \
--body "
## Automated movement of DbScripts_finalization to DbScripts
Automated movement of EDD database scripts.
## Files moved:
Files moved:
$(echo -e "$MOVED_FILES")
")
echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT
@@ -157,5 +203,5 @@ jobs:
env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
with:
message: "Created PR for moving DbScripts_finalization to DbScripts: ${{ steps.create-pr.outputs.pr_url }}"
message: "Created PR for moving EDD database scripts: ${{ steps.create-pr.outputs.pr_url }}"
status: ${{ job.status }}

View File

@@ -30,7 +30,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Verify format
run: dotnet format --verify-no-changes
@@ -95,10 +95,8 @@ jobs:
steps:
- name: Check secrets
id: check-secrets
env:
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
run: |
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }}
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
- name: Check out repo
@@ -119,10 +117,10 @@ jobs:
fi
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
@@ -168,25 +166,22 @@ jobs:
########## Set up Docker ##########
- name: Set up QEMU emulators
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
########## ACRs ##########
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to ACR - production subscription
run: az acr login -n bitwardenprod
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
@@ -242,7 +237,7 @@ jobs:
- name: Build Docker image
id: build-artifacts
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
@@ -257,7 +252,7 @@ jobs:
- name: Install Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
- name: Sign image with Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@@ -274,7 +269,7 @@ jobs:
- name: Scan Docker image
id: container-scan
uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0
uses: anchore/scan-action@2c901ab7378897c01b8efaa2d0c9bf519cc64b9e # v6.2.0
with:
image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false
@@ -287,10 +282,16 @@ jobs:
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
upload:
name: Upload
runs-on: ubuntu-24.04
needs: build-artifacts
permissions:
id-token: write
actions: read
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -298,12 +299,14 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to ACR - production subscription
run: az acr login -n $_AZ_REGISTRY --only-show-errors
@@ -350,6 +353,9 @@ jobs:
cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../..
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Upload Docker stub US artifact
if: |
github.event_name != 'pull_request'
@@ -370,62 +376,23 @@ jobs:
path: docker-stub-EU.zip
if-no-files-found: error
- name: Build Public API Swagger
- name: Build Swagger files
run: |
cd ./src/Api
echo "Restore tools"
dotnet tool restore
echo "Publish"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../swagger.json --host https://api.bitwarden.com \
./obj/build-output/publish/Api.dll public
cd ../..
env:
ASPNETCORE_ENVIRONMENT: Production
swaggerGen: "True"
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
cd ./dev
pwsh ./generate_openapi_files.ps1
- name: Upload Public API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: swagger.json
path: swagger.json
path: api.public.json
if-no-files-found: error
- name: Build Internal API Swagger
run: |
cd ./src/Api
echo "Restore API tools"
dotnet tool restore
echo "Publish API"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../internal.json --host https://api.bitwarden.com \
./obj/build-output/publish/Api.dll internal
cd ../Identity
echo "Restore Identity tools"
dotnet tool restore
echo "Publish Identity"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../identity.json --host https://identity.bitwarden.com \
./obj/build-output/publish/Identity.dll v1
cd ../..
env:
ASPNETCORE_ENVIRONMENT: Development
swaggerGen: "True"
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Internal API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: internal.json
path: internal.json
path: api.json
if-no-files-found: error
- name: Upload Identity Swagger artifact
@@ -458,7 +425,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Print environment
run: |
@@ -496,11 +463,15 @@ jobs:
runs-on: ubuntu-24.04
needs:
- build-artifacts
permissions:
id-token: write
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
@@ -509,8 +480,11 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger self-host build
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
@@ -530,11 +504,15 @@ jobs:
runs-on: ubuntu-22.04
needs:
- build-artifacts
permissions:
id-token: write
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
@@ -543,8 +521,11 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger k8s deploy
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
@@ -572,7 +553,9 @@ jobs:
project: server
pull_request_number: ${{ github.event.number || 0 }}
secrets: inherit
permissions: read-all
permissions:
contents: read
id-token: write
check-failures:
name: Check for failures
@@ -585,6 +568,8 @@ jobs:
- build-mssqlmigratorutility
- self-host-build
- trigger-k8s-deploy
permissions:
id-token: write
steps:
- name: Check if any job failed
if: |
@@ -593,11 +578,12 @@ jobs:
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
if: failure()
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -607,6 +593,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "devops-alerts-slack-webhook-url"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()

View File

@@ -14,6 +14,8 @@ jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
permissions:
contents: read
run-workflow:
name: Run Build on PR Target
@@ -21,3 +23,9 @@ jobs:
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
uses: ./.github/workflows/build.yml
secrets: inherit
permissions:
contents: read
actions: read
id-token: write
security-events: write

View File

@@ -11,11 +11,15 @@ jobs:
build-docker:
name: Remove branch-specific Docker images
runs-on: ubuntu-22.04
permissions:
id-token: write
steps:
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to Azure ACR
run: az acr login -n $_AZ_REGISTRY --only-show-errors
@@ -62,3 +66,6 @@ jobs:
- name: Log out of Docker
run: docker logout
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main

View File

@@ -9,11 +9,16 @@ jobs:
delete-rc:
name: Delete RC Branch
runs-on: ubuntu-22.04
permissions:
contents: write
id-token: write
steps:
- name: Login to Azure - CI Subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve bot secrets
id: retrieve-bot-secrets
@@ -22,6 +27,9 @@ jobs:
keyvault: bitwarden-ci
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Checkout main
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:

View File

@@ -7,19 +7,18 @@ concurrency:
cancel-in-progress: true
jobs:
check-ld-secret:
name: Check for LD secret
check-secret-access:
name: Check for secret access
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-ld-secret.outputs.available }}
permissions:
contents: read
available: ${{ steps.check-secret-access.outputs.available }}
permissions: {}
steps:
- name: Check
id: check-ld-secret
id: check-secret-access
run: |
if [ "${{ secrets.LD_ACCESS_TOKEN }}" != '' ]; then
if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
@@ -28,21 +27,39 @@ jobs:
refs:
name: Code reference collection
runs-on: ubuntu-22.04
needs: check-ld-secret
if: ${{ needs.check-ld-secret.outputs.available == 'true' }}
needs: check-secret-access
if: ${{ needs.check-secret-access.outputs.available == 'true' }}
permissions:
contents: read
pull-requests: write
id-token: write
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-server
secrets: "LD-ACCESS-TOKEN"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Collect
id: collect
uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0
with:
accessToken: ${{ secrets.LD_ACCESS_TOKEN }}
accessToken: ${{ steps.get-kv-secrets.outputs.LD-ACCESS-TOKEN }}
projKey: default
allowTags: true

View File

@@ -4,6 +4,10 @@ on:
pull_request:
types: [labeled]
permissions:
contents: read
id-token: write
jobs:
setup-ephemeral-environment:
name: Setup Ephemeral Environment

112
.github/workflows/load-test.yml vendored Normal file
View File

@@ -0,0 +1,112 @@
name: Load test
on:
schedule:
- cron: "0 0 * * 1" # Run every Monday at 00:00
workflow_dispatch:
inputs:
test-id:
type: string
description: "Identifier label for Datadog metrics"
default: "server-load-test"
k6-test-path:
type: string
description: "Path to load test files"
default: "perf/load/*.js"
k6-flags:
type: string
description: "Additional k6 flags"
api-env-url:
type: string
description: "URL of the API environment"
default: "https://api.qa.bitwarden.pw"
identity-env-url:
type: string
description: "URL of the Identity environment"
default: "https://identity.qa.bitwarden.pw"
permissions:
contents: read
id-token: write
env:
# Secret configuration
AZURE_KEY_VAULT_NAME: gh-server
AZURE_KEY_VAULT_SECRETS: DD-API-KEY, K6-CLIENT-ID, K6-AUTH-USER-EMAIL, K6-AUTH-USER-PASSWORD-HASH
# Specify defaults for scheduled runs
TEST_ID: ${{ inputs.test-id || 'server-load-test' }}
K6_TEST_PATH: ${{ inputs.k6-test-path || 'perf/load/*.js' }}
API_ENV_URL: ${{ inputs.api-env-url || 'https://api.qa.bitwarden.pw' }}
IDENTITY_ENV_URL: ${{ inputs.identity-env-url || 'https://identity.qa.bitwarden.pw' }}
jobs:
run-tests:
name: Run load tests
runs-on: ubuntu-24.04
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: ${{ env.AZURE_KEY_VAULT_NAME }}
secrets: ${{ env.AZURE_KEY_VAULT_SECRETS }}
- name: Log out of Azure
uses: bitwarden/gh-actions/azure-logout@main
# Datadog agent for collecting OTEL metrics from k6
- name: Start Datadog agent
run: |
docker run --detach \
--name datadog-agent \
-p 4317:4317 \
-p 5555:5555 \
-e DD_SITE=us3.datadoghq.com \
-e DD_API_KEY=${{ steps.get-kv-secrets.outputs.DD-API-KEY }} \
-e DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 \
-e DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 \
-e DD_HEALTH_PORT=5555 \
-e HOST_PROC=/proc \
--volume /var/run/docker.sock:/var/run/docker.sock:ro \
--volume /sys/fs/cgroup/:/host/sys/fs/cgroup:ro \
--health-cmd "curl -f http://localhost:5555/health || exit 1" \
--health-interval 10s \
--health-timeout 5s \
--health-retries 10 \
--health-start-period 30s \
--pid host \
datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up k6
uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 # v1.1.0
- name: Run k6 tests
uses: grafana/run-k6-action@c6b79182b9b666aa4f630f4a6be9158ead62536e # v1.2.0
continue-on-error: false
env:
K6_OTEL_METRIC_PREFIX: k6_
K6_OTEL_GRPC_EXPORTER_INSECURE: true
# Load test specific environment variables
API_URL: ${{ env.API_ENV_URL }}
IDENTITY_URL: ${{ env.IDENTITY_ENV_URL }}
CLIENT_ID: ${{ steps.get-kv-secrets.outputs.K6-CLIENT-ID }}
AUTH_USER_EMAIL: ${{ steps.get-kv-secrets.outputs.K6-AUTH-USER-EMAIL }}
AUTH_USER_PASSWORD_HASH: ${{ steps.get-kv-secrets.outputs.K6-AUTH-USER-PASSWORD-HASH }}
with:
flags: >-
--tag test-id=${{ env.TEST_ID }}
-o experimental-opentelemetry
${{ inputs.k6-flags }}
path: ${{ env.K6_TEST_PATH }}

View File

@@ -26,6 +26,9 @@ jobs:
setup:
name: Setup
runs-on: ubuntu-22.04
permissions:
contents: read
deployments: write
outputs:
branch-name: ${{ steps.branch.outputs.branch-name }}
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
@@ -63,6 +66,9 @@ jobs:
name: Publish Docker images
runs-on: ubuntu-22.04
needs: setup
permissions:
contents: read
id-token: write
env:
_RELEASE_VERSION: ${{ needs.setup.outputs.release-version }}
_BRANCH_NAME: ${{ needs.setup.outputs.branch-name }}
@@ -109,10 +115,12 @@ jobs:
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
########## ACR PROD ##########
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to Azure ACR
run: az acr login -n $_AZ_REGISTRY --only-show-errors
@@ -152,12 +160,17 @@ jobs:
- name: Log out of Docker
run: docker logout
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
update-deployment:
name: Update Deployment Status
runs-on: ubuntu-22.04
needs:
- setup
- publish-docker
permissions:
deployments: write
if: ${{ always() && inputs.publish_type != 'Dry Run' }}
steps:
- name: Check if any job failed

View File

@@ -86,7 +86,7 @@ jobs:
- name: Create release
if: ${{ inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
with:
artifacts: "docker-stub-US.zip,
docker-stub-EU.zip,

View File

@@ -22,7 +22,9 @@ on:
required: false
type: string
permissions: {}
permissions:
pull-requests: write
contents: write
jobs:
setup:
@@ -54,7 +56,27 @@ jobs:
- setup
outputs:
version: ${{ steps.set-final-version-output.outputs.version }}
permissions:
id-token: write
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Validate version input format
if: ${{ inputs.version_number_override != '' }}
uses: bitwarden/gh-actions/version-check@main
@@ -62,11 +84,11 @@ jobs:
version: ${{ inputs.version_number_override }}
- name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -158,13 +180,33 @@ jobs:
- setup
- bump_version
runs-on: ubuntu-24.04
permissions:
id-token: write
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -188,8 +230,13 @@ jobs:
git switch --quiet --create $BRANCH_NAME
git push --quiet --set-upstream origin $BRANCH_NAME
move_future_db_scripts:
name: Move finalization database scripts
move_edd_db_scripts:
name: Move EDD database scripts
needs: cut_branch
uses: ./.github/workflows/_move_finalization_db_scripts.yml
permissions:
actions: read
contents: write
id-token: write
pull-requests: write
uses: ./.github/workflows/_move_edd_db_scripts.yml
secrets: inherit

109
.github/workflows/review-code.yml vendored Normal file
View File

@@ -0,0 +1,109 @@
name: Review code
on:
pull_request:
types: [opened, synchronize, reopened]
permissions: {}
jobs:
review:
name: Review
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
pull-requests: write
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Check for Vault team changes
id: check_changes
run: |
# Ensure we have the base branch
git fetch origin ${{ github.base_ref }}
echo "Comparing changes between origin/${{ github.base_ref }} and HEAD"
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
if [ -z "$CHANGED_FILES" ]; then
echo "Zero files changed"
echo "vault_team_changes=false" >> $GITHUB_OUTPUT
exit 0
fi
# Handle variations in spacing and multiple teams
VAULT_PATTERNS=$(grep -E "@bitwarden/team-vault-dev(\s|$)" .github/CODEOWNERS 2>/dev/null | awk '{print $1}')
if [ -z "$VAULT_PATTERNS" ]; then
echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS"
echo "vault_team_changes=false" >> $GITHUB_OUTPUT
exit 0
fi
vault_team_changes=false
for pattern in $VAULT_PATTERNS; do
echo "Checking pattern: $pattern"
# Handle **/directory patterns
if [[ "$pattern" == "**/"* ]]; then
# Remove the **/ prefix
dir_pattern="${pattern#\*\*/}"
# Check if any file contains this directory in its path
if echo "$CHANGED_FILES" | grep -qE "(^|/)${dir_pattern}(/|$)"; then
vault_team_changes=true
echo "✅ Found files matching pattern: $pattern"
echo "$CHANGED_FILES" | grep -E "(^|/)${dir_pattern}(/|$)" | sed 's/^/ - /'
break
fi
else
# Handle other patterns (shouldn't happen based on your CODEOWNERS)
if echo "$CHANGED_FILES" | grep -q "$pattern"; then
vault_team_changes=true
echo "✅ Found files matching pattern: $pattern"
echo "$CHANGED_FILES" | grep "$pattern" | sed 's/^/ - /'
break
fi
fi
done
echo "vault_team_changes=$vault_team_changes" >> $GITHUB_OUTPUT
if [ "$vault_team_changes" = "true" ]; then
echo ""
echo "✅ Vault team changes detected - proceeding with review"
else
echo ""
echo "❌ No Vault team changes detected - skipping review"
fi
- name: Review with Claude Code
if: steps.check_changes.outputs.vault_team_changes == 'true'
uses: anthropics/claude-code-action@a5528eec7426a4f0c9c1ac96018daa53ebd05bc4 # v1.0.7
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
track_progress: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
TITLE: ${{ github.event.pull_request.title }}
BODY: ${{ github.event.pull_request.body }}
AUTHOR: ${{ github.event.pull_request.user.login }}
Please review this pull request with a focus on:
- Code quality and best practices
- Potential bugs or issues
- Security implications
- Performance considerations
Note: The PR branch is already checked out in the current working directory.
Provide detailed feedback using inline comments for specific issues.
claude_args: |
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"

View File

@@ -16,83 +16,40 @@ on:
branches:
- "main"
permissions: {}
jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
permissions:
contents: read
sast:
name: SAST scan
runs-on: ubuntu-22.04
name: Checkmarx
uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main
needs: check-run
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
contents: read
pull-requests: write
security-events: write
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with Checkmarx
uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41
env:
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
with:
project_name: ${{ github.repository }}
cx_tenant: ${{ secrets.CHECKMARX_TENANT }}
base_uri: https://ast.checkmarx.net/
cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }}
cx_client_secret: ${{ secrets.CHECKMARX_SECRET }}
additional_params: |
--report-format sarif \
--filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \
--output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: cx_result.sarif
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
id-token: write
quality:
name: Quality scan
runs-on: ubuntu-22.04
name: Sonar
uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main
needs: check-run
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
contents: read
pull-requests: write
steps:
- name: Set up JDK 17
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
id-token: write
with:
java-version: 17
distribution: "zulu"
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Install SonarCloud scanner
run: dotnet tool install dotnet-sonarscanner -g
- name: Scan with SonarCloud
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" \
/d:sonar.test.inclusions=test/,bitwarden_license/test/ \
/d:sonar.exclusions=test/,bitwarden_license/test/ \
/o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \
/d:sonar.host.url="https://sonarcloud.io" ${{ contains(github.event_name, 'pull_request') && format('/d:sonar.pullrequest.key={0}', github.event.pull_request.number) || '' }}
dotnet build
dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
sonar-config: "dotnet"

View File

@@ -47,7 +47,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Restore tools
run: dotnet tool restore
@@ -154,7 +154,7 @@ jobs:
run: 'docker logs $(docker ps --quiet --filter "name=mssql")'
- name: Report test results
uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
@@ -163,7 +163,7 @@ jobs:
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
- name: Docker Compose down
if: always()
@@ -179,7 +179,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Print environment
run: |
@@ -229,11 +229,27 @@ jobs:
- name: Validate XML
run: |
if grep -q "<Operations>" "report.xml"; then
echo
echo "Migration files are not in sync with the files in the Sql project. Review to make sure that any stored procedures / other db changes match with the stored procedures in the Sql project."
echo "ERROR: Migration files are not in sync with the SQL project"
echo ""
echo "Check these locations:"
echo " - Migration scripts: util/Migrator/DbScripts/"
echo " - SQL project files: src/Sql/"
echo " - Download 'report.xml' artifact for full details"
echo ""
# Show actual SQL differences - exclude database setup commands
if [ -s "diff.sql" ]; then
echo "Key SQL differences:"
# Show meaningful schema differences, filtering out database setup noise
grep -E "^(CREATE|DROP|ALTER)" diff.sql | grep -v "ALTER DATABASE" | grep -v "DatabaseName" | head -5
echo ""
fi
echo "Common causes: naming differences (underscores, case), missing objects, or definition mismatches"
exit 1
else
echo "Report looks good"
echo "SUCCESS: Database validation passed"
fi
shell: bash

View File

@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Print environment
run: |
@@ -49,7 +49,7 @@ jobs:
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
- name: Report test results
uses: dorny/test-reporter@6e6a65b7a0bd2c9197df7d0ae36ac5cee784230c # v2.0.0
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
@@ -58,4 +58,4 @@ jobs:
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3

10
.gitignore vendored
View File

@@ -214,6 +214,9 @@ bitwarden_license/src/Sso/wwwroot/assets
.idea/*
**/**.swp
.mono
src/Core/MailTemplates/Mjml/out
NativeMethods.g.cs
util/RustSdk/rust/target
src/Admin/Admin.zip
src/Api/Api.zip
@@ -225,5 +228,8 @@ src/Notifications/Notifications.zip
bitwarden_license/src/Portal/Portal.zip
bitwarden_license/src/Sso/Sso.zip
**/src/**/flags.json
NativeMethods.g.cs
util/RustSdk/rust/target
# Generated swagger specs
/identity.json
/api.json
/api.public.json

72
CLAUDE.md Normal file
View File

@@ -0,0 +1,72 @@
# Bitwarden Server - Claude Code Configuration
## Critical Rules
- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/` which are generated files
- **NEVER** use code regions: If complexity suggests regions, refactor for better readability
- **NEVER** compromise zero-knowledge principles: User vault data must remain encrypted and inaccessible to Bitwarden
- **NEVER** log or expose sensitive data: No PII, passwords, keys, or vault data in logs or error messages
- **ALWAYS** use secure communication channels: Enforce confidentiality, integrity, and authenticity
- **ALWAYS** encrypt sensitive data: All vault data must be encrypted at rest, in transit, and in use
- **ALWAYS** prioritize cryptographic integrity and data protection
- **ALWAYS** add unit tests (with mocking) for any new feature development
## Project Context
- **Architecture**: Feature and team-based organization
- **Framework**: .NET 8.0, ASP.NET Core
- **Database**: SQL Server primary, EF Core supports PostgreSQL, MySQL/MariaDB, SQLite
- **Testing**: xUnit, NSubstitute
- **Container**: Docker, Docker Compose, Kubernetes/Helm deployable
## Project Structure
- **Source Code**: `/src/` - Services and core infrastructure
- **Tests**: `/test/` - Test logic aligning with the source structure, albeit with a `.Test` suffix
- **Utilities**: `/util/` - Migration tools, seeders, and setup scripts
- **Dev Tools**: `/dev/` - Local development helpers
- **Configuration**: `appsettings.{Environment}.json`, `/dev/secrets.json` for local development
## Security Requirements
- **Compliance**: SOC 2 Type II, SOC 3, HIPAA, ISO 27001, GDPR, CCPA
- **Principles**: Zero-knowledge, end-to-end encryption, secure defaults
- **Validation**: Input sanitization, parameterized queries, rate limiting
- **Logging**: Structured logs, no PII/sensitive data in logs
## Common Commands
- **Build**: `dotnet build`
- **Test**: `dotnet test`
- **Run locally**: `dotnet run --project src/Api`
- **Database update**: `pwsh dev/migrate.ps1`
- **Generate OpenAPI**: `pwsh dev/generate_openapi_files.ps1`
## Code Review Checklist
- Security impact assessed
- xUnit tests added / updated
- Performance impact considered
- Error handling implemented
- Breaking changes documented
- CI passes: build, test, lint
- Feature flags considered for new features
- CODEOWNERS file respected
### Key Architectural Decisions
- Use .NET nullable reference types (ADR 0024)
- TryAdd dependency injection pattern (ADR 0026)
- Authorization patterns (ADR 0022)
- OpenTelemetry for observability (ADR 0020)
- Log to standard output (ADR 0021)
## References
- [Server architecture](https://contributing.bitwarden.com/architecture/server/)
- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/)
- [Contributing guidelines](https://contributing.bitwarden.com/contributing/)
- [Setup guide](https://contributing.bitwarden.com/getting-started/server/guide/)
- [Code style](https://contributing.bitwarden.com/contributing/code-style/)
- [Bitwarden security whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/)
- [Bitwarden security definitions](https://contributing.bitwarden.com/architecture/security/definitions)

View File

@@ -3,62 +3,40 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.6.2</Version>
<Version>2025.10.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<!-- Treat it as a test project if the project hasn't set their own value and it follows our test project conventions -->
<IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject>
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable>
<!-- Uncomment the below line when we are ready to enable nullable repo wide -->
<!-- <Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable> -->
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable>
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
</PropertyGroup>
<!--
This section is for packages that we use multiple times throughout the solution
It gives us a single place to manage the version to ensure we are using the same version
across the solution.
-->
<PropertyGroup>
<!--
NuGet: https://www.nuget.org/packages/Microsoft.NET.Test.Sdk
-->
<MicrosoftNetTestSdkVersion>17.8.0</MicrosoftNetTestSdkVersion>
<!--
NuGet: https://www.nuget.org/packages/xunit
-->
<XUnitVersion>2.6.6</XUnitVersion>
<!--
NuGet: https://www.nuget.org/packages/xunit.runner.visualstudio
-->
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
<!--
NuGet: https://www.nuget.org/packages/coverlet.collector
-->
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
<!--
NuGet: https://www.nuget.org/packages/NSubstitute
-->
<NSubstituteVersion>5.1.0</NSubstituteVersion>
<!--
NuGet: https://www.nuget.org/packages/AutoFixture.Xunit2
-->
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
<!--
NuGet: https://www.nuget.org/packages/AutoFixture.AutoNSubstitute
-->
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
</PropertyGroup>
<!--
This section is for getting & setting the gitHash value, which can easily be accessed
via the Core.Utilities.AssemblyHelpers class.
-->
<Target Name="SetSourceRevisionId" BeforeTargets="CoreGenerateAssemblyInfo">
<Exec Command="git describe --long --always --dirty --exclude=* --abbrev=8" ConsoleToMSBuild="True" IgnoreExitCode="False">
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput"/>
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput" />
</Exec>
</Target>
<Target Name="WriteRevision" AfterTargets="SetSourceRevisionId">

View File

@@ -134,6 +134,7 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -343,6 +344,10 @@ Global
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.Build.0 = Release|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -398,6 +403,7 @@ Global
{9A612EBA-1C0E-42B8-982B-62F0EE81000A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@@ -1,4 +1,6 @@
using Bit.Core;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
@@ -134,25 +136,13 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
};
var setNonUSBusinessUseToReverseCharge = _featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge)
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
else if (customer.HasRecognizedTaxLocation())
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = customer.Address.Country == "US" ||
customer.TaxIds.Any()
};
}
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id;
organization.Status = OrganizationStatusType.Created;
organization.Enabled = true;
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
}

View File

@@ -1,4 +1,7 @@
using System.ComponentModel.DataAnnotations;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
@@ -9,7 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context;
@@ -87,7 +90,7 @@ public class ProviderService : IProviderService
_providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand;
}
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null)
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress)
{
var owner = await _userService.GetUserByIdAsync(ownerUserId);
if (owner == null)
@@ -112,24 +115,7 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid owner.");
}
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
{
throw new BadRequestException("Both address and postal code are required to set up your provider.");
}
var requireProviderPaymentMethodDuringSetup =
_featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource is not
{
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
Token: not null and not ""
})
{
throw new BadRequestException("A payment method is required to set up your provider.");
}
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress);
provider.GatewayCustomerId = customer.Id;
var subscription = await _providerBillingService.SetupSubscription(provider);
provider.GatewaySubscriptionId = subscription.Id;
@@ -149,7 +135,15 @@ public class ProviderService : IProviderService
throw new ArgumentException("Cannot create provider this way.");
}
var existingProvider = await _providerRepository.GetByIdAsync(provider.Id);
var enabledStatusChanged = existingProvider != null && existingProvider.Enabled != provider.Enabled;
await _providerRepository.ReplaceAsync(provider);
if (enabledStatusChanged && (provider.Type == ProviderType.Msp || provider.Type == ProviderType.BusinessUnit))
{
await UpdateClientOrganizationsEnabledStatusAsync(provider.Id, provider.Enabled);
}
}
public async Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite)
@@ -725,4 +719,20 @@ public class ProviderService : IProviderService
throw new BadRequestException($"Unsupported provider type {providerType}.");
}
}
private async Task UpdateClientOrganizationsEnabledStatusAsync(Guid providerId, bool enabled)
{
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
foreach (var providerOrganization in providerOrganizations)
{
var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (organization != null && organization.Enabled != enabled)
{
organization.Enabled = enabled;
await _organizationRepository.ReplaceAsync(organization);
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
}
}
}
}

View File

@@ -1,4 +1,7 @@
using System.Globalization;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Globalization;
using Bit.Core.Billing.Providers.Entities;
using CsvHelper.Configuration.Attributes;

View File

@@ -0,0 +1,107 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Stripe;
using Stripe.Tax;
namespace Bit.Commercial.Core.Billing.Providers.Queries;
using static Bit.Core.Constants;
using static StripeConstants;
using SuspensionWarning = ProviderWarnings.SuspensionWarning;
using TaxIdWarning = ProviderWarnings.TaxIdWarning;
public class GetProviderWarningsQuery(
ICurrentContext currentContext,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IGetProviderWarningsQuery
{
public async Task<ProviderWarnings?> Run(Provider provider)
{
var warnings = new ProviderWarnings();
var subscription =
await subscriberService.GetSubscription(provider,
new SubscriptionGetOptions { Expand = ["customer.tax_ids"] });
if (subscription == null)
{
return warnings;
}
warnings.Suspension = GetSuspensionWarning(provider, subscription);
warnings.TaxId = await GetTaxIdWarningAsync(provider, subscription.Customer);
return warnings;
}
private SuspensionWarning? GetSuspensionWarning(
Provider provider,
Subscription subscription)
{
if (provider.Enabled)
{
return null;
}
return subscription.Status switch
{
SubscriptionStatus.Unpaid => currentContext.ProviderProviderAdmin(provider.Id)
? new SuspensionWarning { Resolution = "add_payment_method", SubscriptionCancelsAt = subscription.CancelAt }
: new SuspensionWarning { Resolution = "contact_administrator" },
_ => new SuspensionWarning { Resolution = "contact_support" }
};
}
private async Task<TaxIdWarning?> GetTaxIdWarningAsync(
Provider provider,
Customer customer)
{
if (customer.Address?.Country == CountryAbbreviations.UnitedStates)
{
return null;
}
if (!currentContext.ProviderProviderAdmin(provider.Id))
{
return null;
}
// TODO: Potentially DRY this out with the GetOrganizationWarningsQuery
// Get active and scheduled registrations
var registrations = (await Task.WhenAll(
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }),
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled })))
.SelectMany(registrations => registrations.Data);
// Find the matching registration for the customer
var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address?.Country);
// If we're not registered in their country, we don't need a warning
if (registration == null)
{
return null;
}
var taxId = customer.TaxIds.FirstOrDefault();
return taxId switch
{
// Customer's tax ID is missing
null => new TaxIdWarning { Type = "tax_id_missing" },
// Not sure if this case is valid, but Stripe says this property is nullable
not null when taxId.Verification == null => null,
// Customer's tax ID is pending verification
not null when taxId.Verification.Status == TaxIdVerificationStatus.Pending => new TaxIdWarning { Type = "tax_id_pending_verification" },
// Customer's tax ID failed verification
not null when taxId.Verification.Status == TaxIdVerificationStatus.Unverified => new TaxIdWarning { Type = "tax_id_failed_verification" },
_ => null
};
}
}

View File

@@ -1,4 +1,7 @@
using System.Globalization;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Globalization;
using Bit.Commercial.Core.Billing.Providers.Models;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
@@ -11,6 +14,7 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Models;
@@ -18,10 +22,8 @@ using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@@ -35,10 +37,12 @@ using Subscription = Stripe.Subscription;
namespace Bit.Commercial.Core.Billing.Providers.Services;
using static Constants;
using static StripeConstants;
public class ProviderBillingService(
IBraintreeGateway braintreeGateway,
IEventService eventService,
IFeatureService featureService,
IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository,
@@ -49,8 +53,7 @@ public class ProviderBillingService(
IProviderUserRepository providerUserRepository,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
ITaxService taxService)
ISubscriberService subscriberService)
: IProviderBillingService
{
public async Task AddExistingOrganization(
@@ -59,10 +62,7 @@ public class ProviderBillingService(
string key)
{
await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = false
});
new SubscriptionUpdateOptions { CancelAtPeriodEnd = false });
var subscription =
await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
@@ -81,7 +81,7 @@ public class ProviderBillingService(
var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now;
if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft)
if (!wasTrialing && subscription.LatestInvoice.Status == InvoiceStatus.Draft)
{
await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId,
new InvoiceFinalizeOptions { AutoAdvance = true });
@@ -182,16 +182,8 @@ public class ProviderBillingService(
{
Items =
[
new SubscriptionItemOptions
{
Price = newPriceId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
{
Id = oldSubscriptionItem.Id,
Deleted = true
}
new SubscriptionItemOptions { Price = newPriceId, Quantity = oldSubscriptionItem!.Quantity },
new SubscriptionItemOptions { Id = oldSubscriptionItem.Id, Deleted = true }
]
};
@@ -200,7 +192,8 @@ public class ProviderBillingService(
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan
// 2. Assign PlanType & PlanName to Organization
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId);
var providerOrganizations =
await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId);
var newPlan = await pricingClient.GetPlanOrThrow(newPlanType);
@@ -211,6 +204,7 @@ public class ProviderBillingService(
{
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = newPlanType;
organization.Plan = newPlan.Name;
await organizationRepository.ReplaceAsync(organization);
@@ -226,15 +220,15 @@ public class ProviderBillingService(
if (!string.IsNullOrEmpty(organization.GatewayCustomerId))
{
logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, nameof(organization.GatewayCustomerId));
logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id,
nameof(organization.GatewayCustomerId));
return;
}
var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions
{
Expand = ["tax", "tax_ids"]
});
var providerCustomer =
await subscriberService.GetCustomerOrThrow(provider,
new CustomerGetOptions { Expand = ["tax", "tax_ids"] });
var providerTaxId = providerCustomer.TaxIds.FirstOrDefault();
@@ -267,25 +261,18 @@ public class ProviderBillingService(
}
]
},
Metadata = new Dictionary<string, string>
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
},
TaxIdData = providerTaxId == null ? null :
Metadata = new Dictionary<string, string> { { "region", globalSettings.BaseServiceUri.CloudRegion } },
TaxIdData = providerTaxId == null
? null
:
[
new CustomerTaxIdDataOptions
{
Type = providerTaxId.Type,
Value = providerTaxId.Value
}
new CustomerTaxIdDataOptions { Type = providerTaxId.Type, Value = providerTaxId.Value }
]
};
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && providerCustomer.Address is not { Country: "US" })
if (providerCustomer.Address is not { Country: CountryAbbreviations.UnitedStates })
{
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse;
customerCreateOptions.TaxExempt = TaxExempt.Reverse;
}
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
@@ -347,9 +334,9 @@ public class ProviderBillingService(
.Where(pair => pair.subscription is
{
Status:
StripeConstants.SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.Trialing or
StripeConstants.SubscriptionStatus.PastDue
SubscriptionStatus.Active or
SubscriptionStatus.Trialing or
SubscriptionStatus.PastDue
}).ToList();
if (active.Count == 0)
@@ -474,35 +461,27 @@ public class ProviderBillingService(
// Below the limit to above the limit
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
// Above the limit to further above the limit
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum &&
newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
}
public async Task<Customer> SetupCustomer(
Provider provider,
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource = null)
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
if (taxInfo is not
{
BillingAddressCountry: not null and not "",
BillingAddressPostalCode: not null and not ""
})
{
logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id);
throw new BillingException();
}
var options = new CustomerCreateOptions
{
Address = new AddressOptions
{
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode,
Line1 = taxInfo.BillingAddressLine1,
Line2 = taxInfo.BillingAddressLine2,
City = taxInfo.BillingAddressCity,
State = taxInfo.BillingAddressState
Country = billingAddress.Country,
PostalCode = billingAddress.PostalCode,
Line1 = billingAddress.Line1,
Line2 = billingAddress.Line2,
City = billingAddress.City,
State = billingAddress.State
},
Coupon = !string.IsNullOrEmpty(provider.DiscountId) ? provider.DiscountId : null,
Description = provider.DisplayBusinessName(),
Email = provider.BillingEmail,
InvoiceSettings = new CustomerInvoiceSettingsOptions
@@ -518,112 +497,71 @@ public class ProviderBillingService(
}
]
},
Metadata = new Dictionary<string, string>
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
}
Metadata = new Dictionary<string, string> { { "region", globalSettings.BaseServiceUri.CloudRegion } },
TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None
};
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && taxInfo.BillingAddressCountry != "US")
if (billingAddress.TaxId != null)
{
options.TaxExempt = StripeConstants.TaxExempt.Reverse;
}
if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber))
{
var taxIdType = taxService.GetStripeTaxCode(
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
if (taxIdType == null)
{
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
options.TaxIdData =
[
new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber }
new CustomerTaxIdDataOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value }
];
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
if (billingAddress.TaxId.Code == TaxIdType.SpanishNIF)
{
options.TaxIdData.Add(new CustomerTaxIdDataOptions
{
Type = StripeConstants.TaxIdType.EUVAT,
Value = $"ES{taxInfo.TaxIdNumber}"
Type = TaxIdType.EUVAT,
Value = $"ES{billingAddress.TaxId.Value}"
});
}
}
if (!string.IsNullOrEmpty(provider.DiscountId))
{
options.Coupon = provider.DiscountId;
}
var requireProviderPaymentMethodDuringSetup =
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
var braintreeCustomerId = "";
if (requireProviderPaymentMethodDuringSetup)
{
if (tokenizedPaymentSource is not
{
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
Token: not null and not ""
})
{
logger.LogError("Cannot create customer for provider ({ProviderID}) without a payment method", provider.Id);
throw new BillingException();
}
var (type, token) = tokenizedPaymentSource;
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (type)
switch (paymentMethod.Type)
{
case PaymentMethodType.BankAccount:
case TokenizablePaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions
{
PaymentMethod = paymentMethod.Token
}))
.FirstOrDefault();
if (setupIntent == null)
{
logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id);
logger.LogError(
"Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account",
provider.Id);
throw new BillingException();
}
await setupIntentCache.Set(provider.Id, setupIntent.Id);
break;
}
case PaymentMethodType.Card:
case TokenizablePaymentMethodType.Card:
{
options.PaymentMethod = token;
options.InvoiceSettings.DefaultPaymentMethod = token;
options.PaymentMethod = paymentMethod.Token;
options.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token;
break;
}
case PaymentMethodType.PayPal:
case TokenizablePaymentMethodType.PayPal:
{
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token);
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, paymentMethod.Token);
options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break;
}
}
}
try
{
return await stripeAdapter.CustomerCreateAsync(options);
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
StripeConstants.ErrorCodes.TaxIdInvalid)
catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid)
{
await Revert();
throw new BadRequestException(
@@ -636,21 +574,19 @@ public class ProviderBillingService(
}
async Task Revert()
{
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource != null)
{
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (tokenizedPaymentSource.Type)
switch (paymentMethod.Type)
{
case PaymentMethodType.BankAccount:
case TokenizablePaymentMethodType.BankAccount:
{
var setupIntentId = await setupIntentCache.Get(provider.Id);
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
await stripeAdapter.SetupIntentCancel(setupIntentId,
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
await setupIntentCache.Remove(provider.Id);
await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id);
break;
}
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break;
@@ -658,7 +594,6 @@ public class ProviderBillingService(
}
}
}
}
public async Task<Subscription> SetupSubscription(
Provider provider)
@@ -670,9 +605,10 @@ public class ProviderBillingService(
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
if (providerPlans == null || providerPlans.Count == 0)
if (providerPlans.Count == 0)
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", provider.Id);
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans",
provider.Id);
throw new BillingException();
}
@@ -685,7 +621,9 @@ public class ProviderBillingService(
if (!providerPlan.IsConfigured())
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name);
logger.LogError(
"Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan",
provider.Id, plan.Name);
throw new BillingException();
}
@@ -698,23 +636,17 @@ public class ProviderBillingService(
});
}
var requireProviderPaymentMethodDuringSetup =
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
var setupIntentId = await setupIntentCache.Get(provider.Id);
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
})
? await stripeAdapter.SetupIntentGet(setupIntentId,
new SetupIntentGetOptions { Expand = ["payment_method"] })
: null;
var usePaymentMethod =
requireProviderPaymentMethodDuringSetup &&
(!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
customer.Metadata.ContainsKey(BraintreeCustomerIdKey) ||
setupIntent.IsUnverifiedBankAccount());
!string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) ||
customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true ||
setupIntent?.IsUnverifiedBankAccount() == true;
int? trialPeriodDays = provider.Type switch
{
@@ -725,35 +657,20 @@ public class ProviderBillingService(
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
CollectionMethod = usePaymentMethod ?
StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice,
CollectionMethod =
usePaymentMethod
? CollectionMethod.ChargeAutomatically
: CollectionMethod.SendInvoice,
Customer = customer.Id,
DaysUntilDue = usePaymentMethod ? null : 30,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
{ "providerId", provider.Id.ToString() }
},
Metadata = new Dictionary<string, string> { { "providerId", provider.Id.ToString() } },
OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
TrialPeriodDays = trialPeriodDays
ProrationBehavior = ProrationBehavior.CreateProrations,
TrialPeriodDays = trialPeriodDays,
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
};
var setNonUSBusinessUseToReverseCharge =
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge)
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
}
else if (customer.HasRecognizedTaxLocation())
{
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = customer.Address.Country == "US" ||
customer.TaxIds.Any()
};
}
try
{
@@ -761,7 +678,7 @@ public class ProviderBillingService(
if (subscription is
{
Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing
Status: SubscriptionStatus.Active or SubscriptionStatus.Trialing
})
{
return subscription;
@@ -775,9 +692,11 @@ public class ProviderBillingService(
throw new BillingException();
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
ErrorCodes.CustomerTaxLocationInvalid)
{
throw new BadRequestException("Your location wasn't recognized. Please ensure your country and postal code are valid.");
throw new BadRequestException(
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
}
}
@@ -791,7 +710,7 @@ public class ProviderBillingService(
subscriberService.UpdateTaxInformation(provider, taxInformation));
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically });
new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically });
}
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
@@ -891,13 +810,9 @@ public class ProviderBillingService(
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
{
Items = [
new SubscriptionItemOptions
{
Id = item.Id,
Price = priceId,
Quantity = newlySubscribedSeats
}
Items =
[
new SubscriptionItemOptions { Id = item.Id, Price = priceId, Quantity = newlySubscribedSeats }
]
});
@@ -920,7 +835,8 @@ public class ProviderBillingService(
var plan = await pricingClient.GetPlanOrThrow(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
.Where(providerOrganization => providerOrganization.Plan == plan.Name &&
providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}

View File

@@ -5,7 +5,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,7 @@
using Bit.Core.SecretsManager.Commands.Porting;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.SecretsManager.Commands.Porting;
using Bit.Core.SecretsManager.Commands.Porting.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;

View File

@@ -1,6 +1,9 @@
using Bit.Core.Context;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Auth.Identity;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Identity;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
using Bit.Core.SecretsManager.Entities;

View File

@@ -1,7 +1,13 @@
using Bit.Core.Repositories;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
namespace Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
@@ -10,15 +16,21 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand
private readonly IAccessPolicyRepository _accessPolicyRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IEventService _eventService;
private readonly ICurrentContext _currentContext;
public CreateServiceAccountCommand(
IAccessPolicyRepository accessPolicyRepository,
IOrganizationUserRepository organizationUserRepository,
IServiceAccountRepository serviceAccountRepository)
IServiceAccountRepository serviceAccountRepository,
IEventService eventService,
ICurrentContext currentContext)
{
_accessPolicyRepository = accessPolicyRepository;
_organizationUserRepository = organizationUserRepository;
_serviceAccountRepository = serviceAccountRepository;
_eventService = eventService;
_currentContext = currentContext;
}
public async Task<ServiceAccount> CreateAsync(ServiceAccount serviceAccount, Guid userId)
@@ -35,6 +47,7 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand
Write = true,
};
await _accessPolicyRepository.CreateManyAsync(new List<BaseAccessPolicy> { accessPolicy });
await _eventService.LogServiceAccountPeopleEventAsync(user.Id, accessPolicy, EventType.ServiceAccount_UserAdded, _currentContext.IdentityClientType);
return createdServiceAccount;
}
}

View File

@@ -1,4 +1,7 @@
using System.Security.Claims;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Security.Claims;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.Queries.Interfaces;

View File

@@ -1,8 +1,10 @@
using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Billing.Providers.Queries;
using Bit.Commercial.Core.Billing.Providers.Services;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Providers.Queries;
using Bit.Core.Billing.Providers.Services;
using Microsoft.Extensions.DependencyInjection;
@@ -17,5 +19,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
services.AddTransient<IProviderBillingService, ProviderBillingService>();
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
services.AddTransient<IGetProviderWarningsQuery, GetProviderWarningsQuery>();
}
}

View File

@@ -28,7 +28,10 @@ public class ProjectRepository : Repository<Core.SecretsManager.Entities.Project
}
}
public async Task<IEnumerable<ProjectPermissionDetails>> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType)
public async Task<IEnumerable<ProjectPermissionDetails>> GetManyByOrganizationIdAsync(
Guid organizationId,
Guid userId,
AccessClientType accessType)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);

View File

@@ -45,6 +45,19 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
}
}
public async Task<IEnumerable<Core.SecretsManager.Entities.Secret>> GetManyTrashedSecretsByIds(IEnumerable<Guid> ids)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var secrets = await dbContext.Secret
.Where(c => ids.Contains(c.Id) && c.DeletedDate != null)
.Include(c => c.Projects)
.ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.Secret>>(secrets);
}
}
public async Task<IEnumerable<Core.SecretsManager.Entities.Secret>> GetManyByOrganizationIdAsync(
Guid organizationId, Guid userId, AccessClientType accessType)
{
@@ -66,10 +79,14 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
return Mapper.Map<List<Core.SecretsManager.Entities.Secret>>(secrets);
}
public async Task<IEnumerable<SecretPermissionDetails>> GetManyDetailsByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType)
public async Task<IEnumerable<SecretPermissionDetails>> GetManyDetailsByOrganizationIdAsync(
Guid organizationId,
Guid userId,
AccessClientType accessType)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.Secret
.Include(c => c.Projects)
.Where(c => c.OrganizationId == organizationId && c.DeletedDate == null)

View File

@@ -1,4 +1,7 @@
using Bit.Core.AdminConsole.Entities;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
using Bit.Core.Enums;

View File

@@ -1,4 +1,7 @@
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;

View File

@@ -1,9 +1,11 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces;
using Bit.Scim.Utilities;
@@ -19,29 +21,28 @@ namespace Bit.Scim.Controllers.v2;
public class UsersController : Controller
{
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService;
private readonly IGetUsersListQuery _getUsersListQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IPatchUserCommand _patchUserCommand;
private readonly IPostUserCommand _postUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
public UsersController(
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
public UsersController(IOrganizationUserRepository organizationUserRepository,
IGetUsersListQuery getUsersListQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IPatchUserCommand patchUserCommand,
IPostUserCommand postUserCommand,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand)
{
_organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_getUsersListQuery = getUsersListQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_patchUserCommand = patchUserCommand;
_postUserCommand = postUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
}
[HttpGet("{id}")]
@@ -98,7 +99,7 @@ public class UsersController : Controller
}
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
{
await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
await _revokeOrganizationUserCommand.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
}
// Have to get full details object for response model

View File

@@ -1,7 +1,7 @@
###############################################
# Build stage #
###############################################
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build
# Docker buildx supplies the value for this arg
ARG TARGETPLATFORM
@@ -9,11 +9,11 @@ ARG TARGETPLATFORM
# Determine proper runtime value for .NET
# We put the value in a file to be read by later layers.
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
RID=linux-x64 ; \
RID=linux-musl-x64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
RID=linux-arm64 ; \
RID=linux-musl-arm64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
RID=linux-arm ; \
RID=linux-musl-arm ; \
fi \
&& echo "RID=$RID" > /tmp/rid.txt
@@ -37,21 +37,21 @@ RUN . /tmp/rid.txt && dotnet publish \
###############################################
# App stage #
###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21
ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden"
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:5000
ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
EXPOSE 5000
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gosu \
curl \
krb5-user \
&& rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache curl \
krb5 \
icu-libs \
shadow \
&& apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu
# Copy app from the build stage
WORKDIR /app

View File

@@ -1,4 +1,7 @@
using Bit.Core.AdminConsole.Entities;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Scim.Groups.Interfaces;

View File

@@ -1,4 +1,7 @@
using System.Text.Json;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;

View File

@@ -1,4 +1,7 @@
using Bit.Scim.Utilities;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Scim.Utilities;
namespace Bit.Scim.Models;

View File

@@ -1,4 +1,7 @@
namespace Bit.Scim.Models;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
namespace Bit.Scim.Models;
public abstract class BaseScimModel
{

View File

@@ -1,4 +1,7 @@
using Bit.Scim.Utilities;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Scim.Utilities;
namespace Bit.Scim.Models;

View File

@@ -1,4 +1,7 @@
using Bit.Scim.Utilities;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Scim.Utilities;
namespace Bit.Scim.Models;

View File

@@ -1,4 +1,7 @@
using Bit.Core.AdminConsole.Entities;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Utilities;
namespace Bit.Scim.Models;

View File

@@ -1,4 +1,7 @@
using Bit.Core.AdminConsole.Entities;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities;
namespace Bit.Scim.Models;

View File

@@ -1,4 +1,7 @@
using Bit.Scim.Utilities;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Scim.Utilities;
namespace Bit.Scim.Models;

View File

@@ -1,4 +1,7 @@
using System.Text.Json;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json;
namespace Bit.Scim.Models;

View File

@@ -1,11 +1,14 @@
using Bit.Core.AdminConsole.Enums;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Utilities;
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
namespace Bit.Scim.Models;
@@ -44,7 +47,7 @@ public class ScimUserRequestModel : BaseScimUserModel
return new InviteOrganizationUsersRequest(
invites:
[
new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite(
new OrganizationUserInviteCommandModel(
email: email,
externalId: ExternalIdForInvite()
)

View File

@@ -8,7 +8,7 @@ using Bit.Core.Utilities;
using Bit.Scim.Context;
using Bit.Scim.Utilities;
using Bit.SharedWeb.Utilities;
using IdentityModel;
using Duende.IdentityModel;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Stripe;

View File

@@ -1,4 +1,7 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Scim.Users.Interfaces;

View File

@@ -1,4 +1,7 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Scim.Models;
namespace Bit.Scim.Users.Interfaces;

View File

@@ -1,8 +1,8 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces;
@@ -11,20 +11,19 @@ namespace Bit.Scim.Users;
public class PatchUserCommand : IPatchUserCommand
{
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly ILogger<PatchUserCommand> _logger;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
public PatchUserCommand(
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
public PatchUserCommand(IOrganizationUserRepository organizationUserRepository,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
ILogger<PatchUserCommand> logger)
ILogger<PatchUserCommand> logger,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand)
{
_organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_logger = logger;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
}
public async Task PatchUserAsync(Guid organizationId, Guid id, ScimPatchModel model)
@@ -80,7 +79,7 @@ public class PatchUserCommand : IPatchUserCommand
}
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)
{
await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
await _revokeOrganizationUserCommand.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
return true;
}
return false;

View File

@@ -3,7 +3,7 @@ using System.Text.Encodings.Web;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Scim.Context;
using IdentityModel;
using Duende.IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/sh
# Setup
@@ -37,7 +37,7 @@ then
mkdir -p /etc/bitwarden/ca-certificates
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
fi
@@ -46,13 +46,13 @@ else
gosu_cmd=""
fi
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
$gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
fi
if [[ $globalSettings__selfHosted == "true" ]]; then
if [[ -z $globalSettings__identityServer__certificateLocation ]]; then
if [ "$globalSettings__selfHosted" = "true" ]; then
if [ -z "$globalSettings__identityServer__certificateLocation" ]; then
export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx
fi
fi

View File

@@ -1,5 +1,9 @@
using System.Security.Claims;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Security.Claims;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
@@ -19,10 +23,10 @@ using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Bit.Sso.Models;
using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -104,36 +108,32 @@ public class AccountController : Controller
// Validate domain_hint provided
if (string.IsNullOrWhiteSpace(domainHint))
{
return InvalidJson("NoOrganizationIdentifierProvidedError");
_logger.LogError(new ArgumentException("domainHint is required."), "domainHint not specified.");
return InvalidJson("SsoInvalidIdentifierError");
}
// Validate organization exists from domain_hint
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
if (organization == null)
if (organization is not { UseSso: true })
{
return InvalidJson("OrganizationNotFoundByIdentifierError");
}
if (!organization.UseSso)
{
return InvalidJson("SsoNotAllowedForOrganizationError");
_logger.LogError("Organization not configured to use SSO.");
return InvalidJson("SsoInvalidIdentifierError");
}
// Validate SsoConfig exists and is Enabled
var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint);
if (ssoConfig == null)
if (ssoConfig is not { Enabled: true })
{
return InvalidJson("SsoConfigurationNotFoundForOrganizationError");
}
if (!ssoConfig.Enabled)
{
return InvalidJson("SsoNotEnabledForOrganizationError");
_logger.LogError("SsoConfig not enabled.");
return InvalidJson("SsoInvalidIdentifierError");
}
// Validate Authentication Scheme exists and is loaded (cache)
var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString());
if (scheme == null || !(scheme is IDynamicAuthenticationScheme dynamicScheme))
if (scheme is not IDynamicAuthenticationScheme dynamicScheme)
{
return InvalidJson("NoSchemeOrHandlerForSsoConfigurationFoundError");
_logger.LogError("Invalid authentication scheme for organization.");
return InvalidJson("SsoInvalidIdentifierError");
}
// Run scheme validation
@@ -143,13 +143,8 @@ public class AccountController : Controller
}
catch (Exception ex)
{
var translatedException = _i18nService.GetLocalizedHtmlString(ex.Message);
var errorKey = "InvalidSchemeConfigurationError";
if (!translatedException.ResourceNotFound)
{
errorKey = ex.Message;
}
return InvalidJson(errorKey, translatedException.ResourceNotFound ? ex : null);
_logger.LogError(ex, "An error occurred while validating SSO dynamic scheme.");
return InvalidJson("SsoInvalidIdentifierError");
}
var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds);
@@ -159,7 +154,8 @@ public class AccountController : Controller
}
catch (Exception ex)
{
return InvalidJson("PreValidationError", ex);
_logger.LogError(ex, "An error occurred during SSO prevalidation.");
return InvalidJson("SsoInvalidIdentifierError");
}
}
@@ -251,18 +247,23 @@ public class AccountController : Controller
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
_logger.LogDebug("External claims: {@claims}", externalClaims);
// Lookup our user and external provider info
// See if the user has logged in with this SSO provider before and has already been provisioned.
// This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using.
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
// The user has not authenticated with this SSO provider before.
// They could have an existing Bitwarden account in the User table though.
if (user == null)
{
// This might be where you might initiate a custom workflow for user registration
// in this sample we don't show how that would be done, as our sample implementation
// simply auto-provisions new external user
// If we're manually linking to SSO, the user's external identifier will be passed as query string parameter.
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ?
result.Properties.Items["user_identifier"] : null;
user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData);
}
// Either the user already authenticated with the SSO provider, or we've just provisioned them.
// Either way, we have associated the SSO login with a Bitwarden user.
// We will now sign the Bitwarden user in.
if (user != null)
{
// This allows us to collect any additional claims or properties
@@ -342,6 +343,10 @@ public class AccountController : Controller
}
}
/// <summary>
/// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`.
/// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records.
/// </summary>
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims, SsoConfigurationData config)>
FindUserFromExternalProviderAsync(AuthenticateResult result)
{
@@ -399,6 +404,23 @@ public class AccountController : Controller
return (user, provider, providerUserId, claims, ssoConfigData);
}
/// <summary>
/// Provision an SSO-linked Bitwarden user.
/// This handles three different scenarios:
/// 1. Creating an SsoUser link for an existing User and OrganizationUser
/// - User is a member of the organization, but hasn't authenticated with the org's SSO provider before.
/// 2. Creating a new User and a new OrganizationUser, then establishing an SsoUser link
/// - User is joining the organization through JIT provisioning, without a pending invitation
/// 3. Creating a new User for an existing OrganizationUser (created by invitation), then establishing an SsoUser link
/// - User is signing in with a pending invitation.
/// </summary>
/// <param name="provider">The external identity provider.</param>
/// <param name="providerUserId">The external identity provider's user identifier.</param>
/// <param name="claims">The claims from the external IdP.</param>
/// <param name="userIdentifier">The user identifier used for manual SSO linking.</param>
/// <param name="config">The SSO configuration for the organization.</param>
/// <returns>The User to sign in.</returns>
/// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception>
private async Task<User> AutoProvisionUserAsync(string provider, string providerUserId,
IEnumerable<Claim> claims, string userIdentifier, SsoConfigurationData config)
{
@@ -426,50 +448,15 @@ public class AccountController : Controller
}
else
{
var split = userIdentifier.Split(",");
if (split.Length < 2)
{
throw new Exception(_i18nService.T("InvalidUserIdentifier"));
}
var userId = split[0];
var token = split[1];
var tokenOptions = new TokenOptions();
var claimedUser = await _userService.GetUserByIdAsync(userId);
if (claimedUser != null)
{
var tokenIsValid = await _userManager.VerifyUserTokenAsync(
claimedUser, tokenOptions.PasswordResetTokenProvider, TokenPurposes.LinkSso, token);
if (tokenIsValid)
{
existingUser = claimedUser;
}
else
{
throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
}
}
existingUser = await GetUserFromManualLinkingData(userIdentifier);
}
OrganizationUser orgUser = null;
var organization = await _organizationRepository.GetByIdAsync(orgId);
if (organization == null)
{
throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId));
}
// Try to find the OrganizationUser if it exists.
var (organization, orgUser) = await FindOrganizationUser(existingUser, email, orgId);
// Try to find OrgUser via existing User Id (accepted/confirmed user)
if (existingUser != null)
{
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id);
orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId);
}
// If no Org User found by Existing User Id - search all organization users via email
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email);
// All Existing User flows handled below
//----------------------------------------------------
// Scenario 1: We've found the user in the User table
//----------------------------------------------------
if (existingUser != null)
{
if (existingUser.UsesKeyConnector &&
@@ -478,20 +465,22 @@ public class AccountController : Controller
throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector"));
}
// If the user already exists in Bitwarden, we require that the user already be in the org,
// and that they are either Accepted or Confirmed.
if (orgUser == null)
{
// Org User is not created - no invite has been sent
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
}
if (orgUser.Status == OrganizationUserStatusType.Invited)
{
// Org User is invited - they must manually accept the invite via email and authenticate with MP
// This allows us to enroll them in MP reset if required
throw new Exception(_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
}
EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(),
allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]);
// Accepted or Confirmed - create SSO link and return;
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not
// authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them).
// We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
// with authentication.
await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser);
return existingUser;
}
@@ -534,7 +523,9 @@ public class AccountController : Controller
emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false;
}
// Create user record - all existing user flows are handled above
//--------------------------------------------------
// Scenarios 2 and 3: We need to register a new user
//--------------------------------------------------
var user = new User
{
Name = name,
@@ -560,7 +551,11 @@ public class AccountController : Controller
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email);
}
// Create Org User if null or else update existing Org User
//-----------------------------------------------------------------
// Scenario 2: We also need to create an OrganizationUser
// This means that an invitation was not sent for this user and we
// need to establish their invited status now.
//-----------------------------------------------------------------
if (orgUser == null)
{
orgUser = new OrganizationUser
@@ -572,18 +567,107 @@ public class AccountController : Controller
};
await _organizationUserRepository.CreateAsync(orgUser);
}
//-----------------------------------------------------------------
// Scenario 3: There is already an existing OrganizationUser
// That was established through an invitation. We just need to
// update the UserId now that we have created a User record.
//-----------------------------------------------------------------
else
{
orgUser.UserId = user.Id;
await _organizationUserRepository.ReplaceAsync(orgUser);
}
// Create sso user record
// Create the SsoUser record to link the user to the SSO provider.
await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser);
return user;
}
private async Task<User> GetUserFromManualLinkingData(string userIdentifier)
{
User user = null;
var split = userIdentifier.Split(",");
if (split.Length < 2)
{
throw new Exception(_i18nService.T("InvalidUserIdentifier"));
}
var userId = split[0];
var token = split[1];
var tokenOptions = new TokenOptions();
var claimedUser = await _userService.GetUserByIdAsync(userId);
if (claimedUser != null)
{
var tokenIsValid = await _userManager.VerifyUserTokenAsync(
claimedUser, tokenOptions.PasswordResetTokenProvider, TokenPurposes.LinkSso, token);
if (tokenIsValid)
{
user = claimedUser;
}
else
{
throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
}
}
return user;
}
private async Task<(Organization, OrganizationUser)> FindOrganizationUser(User existingUser, string email, Guid orgId)
{
OrganizationUser orgUser = null;
var organization = await _organizationRepository.GetByIdAsync(orgId);
if (organization == null)
{
throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId));
}
// Try to find OrgUser via existing User Id.
// This covers any OrganizationUser state after they have accepted an invite.
if (existingUser != null)
{
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id);
orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId);
}
// If no Org User found by Existing User Id - search all the organization's users via email.
// This covers users who are Invited but haven't accepted their invite yet.
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email);
return (organization, orgUser);
}
private void EnsureOrgUserStatusAllowed(
OrganizationUserStatusType status,
string organizationDisplayName,
params OrganizationUserStatusType[] allowedStatuses)
{
// if this status is one of the allowed ones, just return
if (allowedStatuses.Contains(status))
{
return;
}
// otherwise throw the appropriate exception
switch (status)
{
case OrganizationUserStatusType.Invited:
// Org User is invited must accept via email first
throw new Exception(
_i18nService.T("AcceptInviteBeforeUsingSSO", organizationDisplayName));
case OrganizationUserStatusType.Revoked:
// Revoked users may not be (auto)provisioned
throw new Exception(
_i18nService.T("OrganizationUserAccessRevoked", organizationDisplayName));
default:
// anything else is “unknown”
throw new Exception(
_i18nService.T("OrganizationUserUnknownStatus", organizationDisplayName));
}
}
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
{
Response.StatusCode = ex == null ? 400 : 500;

View File

@@ -1,4 +1,7 @@
using System.Diagnostics;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Diagnostics;
using Bit.Sso.Models;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authorization;

View File

@@ -1,7 +1,7 @@
###############################################
# Build stage #
###############################################
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build
# Docker buildx supplies the value for this arg
ARG TARGETPLATFORM
@@ -9,11 +9,11 @@ ARG TARGETPLATFORM
# Determine proper runtime value for .NET
# We put the value in a file to be read by later layers.
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
RID=linux-x64 ; \
RID=linux-musl-x64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
RID=linux-arm64 ; \
RID=linux-musl-arm64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
RID=linux-arm ; \
RID=linux-musl-arm ; \
fi \
&& echo "RID=$RID" > /tmp/rid.txt
@@ -37,21 +37,21 @@ RUN . /tmp/rid.txt && dotnet publish \
###############################################
# App stage #
###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21
ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden"
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:5000
ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
EXPOSE 5000
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gosu \
curl \
krb5-user \
&& rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache curl \
krb5 \
icu-libs \
shadow \
&& apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu
# Copy app from the build stage
WORKDIR /app

View File

@@ -1,4 +1,7 @@
using Duende.IdentityServer.Models;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Duende.IdentityServer.Models;
namespace Bit.Sso.Models;

View File

@@ -1,4 +1,7 @@
namespace Bit.Sso.Models;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
namespace Bit.Sso.Models;
public class RedirectViewModel
{

View File

@@ -1,4 +1,7 @@
using System.Security.Cryptography.X509Certificates;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Security.Cryptography.X509Certificates;
namespace Bit.Sso.Models;

View File

@@ -10,7 +10,7 @@
<!-- This is a transitive dependency to Sustainsys.Saml2.AspNetCore2 -->
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.10.0" />
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.11.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,4 +1,7 @@
using System.Security.Claims;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Security.Claims;
using System.Text.RegularExpressions;
namespace Bit.Sso.Utilities;

View File

@@ -1,15 +1,19 @@
using System.Security.Cryptography.X509Certificates;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Security.Cryptography.X509Certificates;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Sso.Models;
using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer;
using Duende.IdentityServer.Infrastructure;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;
@@ -413,7 +417,7 @@ public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider
SPOptions = spOptions,
SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme,
SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme,
CookieManager = new IdentityServer.DistributedCacheCookieManager(),
CookieManager = new DistributedCacheCookieManager(),
};
options.IdentityProviders.Add(idp);

View File

@@ -1,4 +1,7 @@
using System.Collections.Concurrent;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
namespace Bit.Sso.Utilities;

View File

@@ -1,4 +1,7 @@
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
namespace Bit.Sso.Utilities;

View File

@@ -1,4 +1,7 @@
using System.IO.Compression;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.IO.Compression;
using System.Text;
using System.Xml;
using Sustainsys.Saml2;

View File

@@ -1,4 +1,7 @@
using Bit.Core.Business.Sso;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Business.Sso;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/sh
# Setup
@@ -37,7 +37,7 @@ then
mkdir -p /etc/bitwarden/ca-certificates
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
fi
@@ -46,13 +46,13 @@ else
gosu_cmd=""
fi
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
$gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
fi
if [[ $globalSettings__selfHosted == "true" ]]; then
if [[ -z $globalSettings__identityServer__certificateLocation ]]; then
if [ "$globalSettings__selfHosted" = "true" ]; then
if [ -z "$globalSettings__identityServer__certificateLocation" ]; then
export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx
fi
fi

View File

@@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.88.0",
"sass": "1.91.0",
"sass-loader": "16.0.5",
"webpack": "5.99.8",
"webpack": "5.101.3",
"webpack-cli": "5.1.4"
}
},
@@ -34,18 +34,14 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@@ -58,20 +54,10 @@
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -80,16 +66,16 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"version": "0.3.30",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -441,9 +427,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
@@ -455,13 +441,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.15.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
"integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
"version": "24.3.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~7.10.0"
}
},
"node_modules/@webassemblyjs/ast": {
@@ -687,9 +673,9 @@
"license": "Apache-2.0"
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -699,6 +685,19 @@
"node": ">=0.4.0"
}
},
"node_modules/acorn-import-phases": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
},
"peerDependencies": {
"acorn": "^8.14.0"
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@@ -781,9 +780,9 @@
}
},
"node_modules/browserslist": {
"version": "4.24.5",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz",
"integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==",
"version": "4.25.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz",
"integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==",
"dev": true,
"funding": [
{
@@ -801,8 +800,8 @@
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001716",
"electron-to-chromium": "^1.5.149",
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
"node-releases": "^2.0.19",
"update-browserslist-db": "^1.1.3"
},
@@ -821,9 +820,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001718",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz",
"integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==",
"version": "1.0.30001741",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz",
"integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==",
"dev": true,
"funding": [
{
@@ -975,16 +974,16 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.155",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz",
"integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==",
"version": "1.5.215",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz",
"integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==",
"dev": true,
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1107,9 +1106,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"dev": true,
"funding": [
{
@@ -1241,9 +1240,9 @@
}
},
"node_modules/immutable": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz",
"integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==",
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
"integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
"dev": true,
"license": "MIT"
},
@@ -1528,9 +1527,9 @@
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz",
"integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==",
"dev": true,
"license": "MIT"
},
@@ -1635,9 +1634,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@@ -1655,7 +1654,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.8",
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1860,9 +1859,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.88.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.88.0.tgz",
"integrity": "sha512-sF6TWQqjFvr4JILXzG4ucGOLELkESHL+I5QJhh7CNaE+Yge0SI+ehCatsXhJ7ymU1hAFcIS3/PBpjdIbXoyVbg==",
"version": "1.91.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz",
"integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2061,24 +2060,28 @@
}
},
"node_modules/tapable": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz",
"integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/terser": {
"version": "5.39.2",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz",
"integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==",
"version": "5.44.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz",
"integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.14.0",
"acorn": "^8.15.0",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
@@ -2139,9 +2142,9 @@
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"dev": true,
"license": "MIT"
},
@@ -2198,22 +2201,23 @@
}
},
"node_modules/webpack": {
"version": "5.99.8",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz",
"integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==",
"version": "5.101.3",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz",
"integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
"@types/estree": "^1.0.8",
"@types/json-schema": "^7.0.15",
"@webassemblyjs/ast": "^1.14.1",
"@webassemblyjs/wasm-edit": "^1.14.1",
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.14.0",
"acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.24.0",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.1",
"enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
@@ -2227,7 +2231,7 @@
"tapable": "^2.1.1",
"terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.1",
"webpack-sources": "^3.2.3"
"webpack-sources": "^3.3.3"
},
"bin": {
"webpack": "bin/webpack.js"
@@ -2317,9 +2321,9 @@
}
},
"node_modules/webpack-sources": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
"integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz",
"integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -16,9 +16,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.88.0",
"sass": "1.91.0",
"sass-loader": "16.0.5",
"webpack": "5.99.8",
"webpack": "5.101.3",
"webpack-cli": "5.1.4"
}
}

View File

@@ -1,5 +1,4 @@
using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
@@ -263,7 +262,8 @@ public class RemoveOrganizationFromProviderCommandTests
org =>
org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "subscription_id" &&
org.Status == OrganizationStatusType.Created));
org.Status == OrganizationStatusType.Created &&
org.Enabled == true)); // Verify organization is enabled when new subscription is created
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
@@ -331,9 +331,6 @@ public class RemoveOrganizationFromProviderCommandTests
Id = "subscription_id"
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
@@ -354,7 +351,8 @@ public class RemoveOrganizationFromProviderCommandTests
org =>
org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "subscription_id" &&
org.Status == OrganizationStatusType.Created));
org.Status == OrganizationStatusType.Created &&
org.Enabled == true)); // Verify organization is enabled when new subscription is created
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
@@ -390,4 +388,62 @@ public class RemoveOrganizationFromProviderCommandTests
}
}
};
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_DisabledOrganization_ConsolidatedBilling_EnablesOrganization(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
// Arrange: Set up a disabled organization that meets the criteria for consolidated billing
provider.Status = ProviderStatusType.Billable;
providerOrganization.ProviderId = provider.Id;
organization.Status = OrganizationStatusType.Managed;
organization.PlanType = PlanType.TeamsMonthly;
organization.Enabled = false; // Start with a disabled organization
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
[],
includeProvider: false)
.Returns(true);
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
"owner@example.com"
]);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Any<CustomerUpdateOptions>())
.Returns(new Customer
{
Id = "customer_id",
Address = new Address
{
Country = "US"
}
});
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "new_subscription_id"
});
// Act
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
// Assert: Verify the disabled organization is now enabled
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org =>
org.Enabled == true && // The previously disabled organization should now be enabled
org.Status == OrganizationStatusType.Created &&
org.GatewaySubscriptionId == "new_subscription_id"));
}
}

View File

@@ -1,15 +1,15 @@
using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context;
@@ -41,7 +41,7 @@ public class ProviderServiceTests
public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
{
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null));
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null, null));
Assert.Contains("Invalid owner.", exception.Message);
}
@@ -53,85 +53,12 @@ public class ProviderServiceTests
userService.GetUserByIdAsync(user.Id).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null));
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null, null));
Assert.Contains("Invalid token.", exception.Message);
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException(
User user,
Provider provider,
string key,
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource,
[ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
providerUser.ProviderId = provider.Id;
providerUser.UserId = user.Id;
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByIdAsync(user.Id).Returns(user);
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.Create();
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
taxInfo.BillingAddressCountry = null;
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
Assert.Equal("Both address and postal code are required to set up your provider.", exception.Message);
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_InvalidTokenizedPaymentSource_ThrowsBadRequestException(
User user,
Provider provider,
string key,
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource,
[ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
providerUser.ProviderId = provider.Id;
providerUser.UserId = user.Id;
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByIdAsync(user.Id).Returns(user);
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.Create();
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
Assert.Equal("A payment method is required to set up your provider.", exception.Message);
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource,
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress,
[ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
@@ -151,7 +78,7 @@ public class ProviderServiceTests
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
var customer = new Customer { Id = "customer_id" };
providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer);
providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer);
var subscription = new Subscription { Id = "subscription_id" };
providerBillingService.SetupSubscription(provider).Returns(subscription);
@@ -160,7 +87,7 @@ public class ProviderServiceTests
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource);
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, tokenizedPaymentMethod, billingAddress);
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
p =>
@@ -188,6 +115,262 @@ public class ProviderServiceTests
await sutProvider.Sut.UpdateAsync(provider);
}
[Theory, BitAutoData]
public async Task UpdateAsync_ExistingProviderIsNull_DoesNotCallUpdateClientOrganizationsEnabledStatus(
Provider provider, SutProvider<ProviderService> sutProvider)
{
// Arrange
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns((Provider)null);
// Act
await sutProvider.Sut.UpdateAsync(provider);
// Assert
await providerRepository.Received(1).ReplaceAsync(provider);
await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task UpdateAsync_EnabledStatusNotChanged_DoesNotCallUpdateClientOrganizationsEnabledStatus(
Provider provider, Provider existingProvider, SutProvider<ProviderService> sutProvider)
{
// Arrange
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
existingProvider.Id = provider.Id;
existingProvider.Enabled = provider.Enabled; // Same enabled status
provider.Type = ProviderType.Msp; // Set to a type that would trigger update if status changed
providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider);
// Act
await sutProvider.Sut.UpdateAsync(provider);
// Assert
await providerRepository.Received(1).ReplaceAsync(provider);
await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task UpdateAsync_EnabledStatusChangedButProviderTypeIsReseller_DoesNotCallUpdateClientOrganizationsEnabledStatus(
Provider provider, Provider existingProvider, SutProvider<ProviderService> sutProvider)
{
// Arrange
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
existingProvider.Id = provider.Id;
existingProvider.Enabled = !provider.Enabled; // Different enabled status
provider.Type = ProviderType.Reseller; // Type that should not trigger update
providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider);
// Act
await sutProvider.Sut.UpdateAsync(provider);
// Assert
await providerRepository.Received(1).ReplaceAsync(provider);
await providerOrganizationRepository.DidNotReceive().GetManyDetailsByProviderAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task UpdateAsync_EnabledStatusChangedAndProviderTypeIsMsp_CallsUpdateClientOrganizationsEnabledStatus(
Provider provider, Provider existingProvider, SutProvider<ProviderService> sutProvider)
{
// Arrange
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
existingProvider.Id = provider.Id;
existingProvider.Enabled = !provider.Enabled; // Different enabled status
provider.Type = ProviderType.Msp; // Type that should trigger update
// Create test provider organization details
var providerOrganizationDetails = new List<ProviderOrganizationOrganizationDetails>
{
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() },
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }
};
// Create test organizations with different enabled status than what we're setting
var organizations = providerOrganizationDetails.Select(po =>
{
var org = new Organization { Id = po.OrganizationId, Enabled = !provider.Enabled };
return org;
}).ToList();
providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider);
providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails);
foreach (var org in organizations)
{
organizationRepository.GetByIdAsync(org.Id).Returns(org);
}
// Act
await sutProvider.Sut.UpdateAsync(provider);
// Assert
await providerRepository.Received(1).ReplaceAsync(provider);
await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id);
foreach (var org in organizations)
{
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(o =>
o.Id == org.Id && o.Enabled == provider.Enabled));
await applicationCacheService.Received(1).UpsertOrganizationAbilityAsync(Arg.Is<Organization>(o =>
o.Id == org.Id && o.Enabled == provider.Enabled));
}
}
[Theory, BitAutoData]
public async Task UpdateAsync_EnabledStatusChangedAndProviderTypeIsBusinessUnit_CallsUpdateClientOrganizationsEnabledStatus(
Provider provider, Provider existingProvider, SutProvider<ProviderService> sutProvider)
{
// Arrange
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
existingProvider.Id = provider.Id;
existingProvider.Enabled = !provider.Enabled; // Different enabled status
provider.Type = ProviderType.BusinessUnit; // Type that should trigger update
// Create test provider organization details
var providerOrganizationDetails = new List<ProviderOrganizationOrganizationDetails>
{
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() },
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }
};
// Create test organizations with different enabled status than what we're setting
var organizations = providerOrganizationDetails.Select(po =>
{
var org = new Organization { Id = po.OrganizationId, Enabled = !provider.Enabled };
return org;
}).ToList();
providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider);
providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails);
foreach (var org in organizations)
{
organizationRepository.GetByIdAsync(org.Id).Returns(org);
}
// Act
await sutProvider.Sut.UpdateAsync(provider);
// Assert
await providerRepository.Received(1).ReplaceAsync(provider);
await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id);
foreach (var org in organizations)
{
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(o =>
o.Id == org.Id && o.Enabled == provider.Enabled));
await applicationCacheService.Received(1).UpsertOrganizationAbilityAsync(Arg.Is<Organization>(o =>
o.Id == org.Id && o.Enabled == provider.Enabled));
}
}
[Theory, BitAutoData]
public async Task UpdateAsync_OrganizationEnabledStatusAlreadyMatches_DoesNotUpdateOrganization(
Provider provider, Provider existingProvider, SutProvider<ProviderService> sutProvider)
{
// Arrange
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
existingProvider.Id = provider.Id;
existingProvider.Enabled = !provider.Enabled; // Different enabled status
provider.Type = ProviderType.Msp; // Type that should trigger update
// Create test provider organization details
var providerOrganizationDetails = new List<ProviderOrganizationOrganizationDetails>
{
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() },
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }
};
// Create test organizations with SAME enabled status as what we're setting
var organizations = providerOrganizationDetails.Select(po =>
{
var org = new Organization { Id = po.OrganizationId, Enabled = provider.Enabled };
return org;
}).ToList();
providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider);
providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails);
foreach (var org in organizations)
{
organizationRepository.GetByIdAsync(org.Id).Returns(org);
}
// Act
await sutProvider.Sut.UpdateAsync(provider);
// Assert
await providerRepository.Received(1).ReplaceAsync(provider);
await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id);
// Organizations should not be updated since their enabled status already matches
foreach (var org in organizations)
{
await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await applicationCacheService.DidNotReceive().UpsertOrganizationAbilityAsync(Arg.Any<Organization>());
}
}
[Theory, BitAutoData]
public async Task UpdateAsync_OrganizationIsNull_SkipsNullOrganization(
Provider provider, Provider existingProvider, SutProvider<ProviderService> sutProvider)
{
// Arrange
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
existingProvider.Id = provider.Id;
existingProvider.Enabled = !provider.Enabled; // Different enabled status
provider.Type = ProviderType.Msp; // Type that should trigger update
// Create test provider organization details
var providerOrganizationDetails = new List<ProviderOrganizationOrganizationDetails>
{
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() },
new ProviderOrganizationOrganizationDetails { Id = Guid.NewGuid(), ProviderId = provider.Id, OrganizationId = Guid.NewGuid() }
};
providerRepository.GetByIdAsync(provider.Id).Returns(existingProvider);
providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id).Returns(providerOrganizationDetails);
// Return null for all organizations
organizationRepository.GetByIdAsync(Arg.Any<Guid>()).Returns((Organization)null);
// Act
await sutProvider.Sut.UpdateAsync(provider);
// Assert
await providerRepository.Received(1).ReplaceAsync(provider);
await providerOrganizationRepository.Received(1).GetManyDetailsByProviderAsync(provider.Id);
// No organizations should be updated since they're all null
await organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
await applicationCacheService.DidNotReceive().UpsertOrganizationAbilityAsync(Arg.Any<Organization>());
}
[Theory, BitAutoData]
public async Task InviteUserAsync_ProviderIdIsInvalid_Throws(ProviderUserInvite<string> invite, SutProvider<ProviderService> sutProvider)
{
@@ -937,7 +1120,7 @@ public class ProviderServiceTests
private static SubscriptionUpdateOptions SubscriptionUpdateRequest(string expectedPlanId, Subscription subscriptionItem) =>
new()
{
Items = new List<Stripe.SubscriptionItemOptions>
Items = new List<SubscriptionItemOptions>
{
new() { Id = subscriptionItem.Id, Price = expectedPlanId },
}

View File

@@ -0,0 +1,556 @@
using Bit.Commercial.Core.Billing.Providers.Queries;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Stripe;
using Stripe.Tax;
using Xunit;
namespace Bit.Commercial.Core.Test.Billing.Providers.Queries;
using static StripeConstants;
[SutProviderCustomize]
public class GetProviderWarningsQueryTests
{
private static readonly string[] _requiredExpansions = ["customer.tax_ids"];
[Theory, BitAutoData]
public async Task Run_NoSubscription_NoWarnings(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.ReturnsNull();
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
Suspension: null,
TaxId: null
});
}
[Theory, BitAutoData]
public async Task Run_ProviderEnabled_NoSuspensionWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Unpaid,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
Assert.Null(response!.Suspension);
}
[Theory, BitAutoData]
public async Task Run_Has_SuspensionWarning_AddPaymentMethod(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = false;
var cancelAt = DateTime.UtcNow.AddDays(7);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Unpaid,
CancelAt = cancelAt,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
Suspension.Resolution: "add_payment_method"
});
Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt);
}
[Theory, BitAutoData]
public async Task Run_Has_SuspensionWarning_ContactAdministrator(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = false;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Unpaid,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
Suspension.Resolution: "contact_administrator"
});
Assert.Null(response.Suspension.SubscriptionCancelsAt);
}
[Theory, BitAutoData]
public async Task Run_Has_SuspensionWarning_ContactSupport(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = false;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Canceled,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
Suspension.Resolution: "contact_support"
});
}
[Theory, BitAutoData]
public async Task Run_NotProviderAdmin_NoTaxIdWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
var response = await sutProvider.Sut.Run(provider);
Assert.Null(response!.TaxId);
}
[Theory, BitAutoData]
public async Task Run_NoTaxRegistrationForCountry_NoTaxIdWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "GB" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.Null(response!.TaxId);
}
[Theory, BitAutoData]
public async Task Run_Has_TaxIdMissingWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
TaxId.Type: "tax_id_missing"
});
}
[Theory, BitAutoData]
public async Task Run_TaxIdVerificationIsNull_NoTaxIdWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId>
{
Data = [new TaxId { Verification = null }]
},
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.Null(response!.TaxId);
}
[Theory, BitAutoData]
public async Task Run_Has_TaxIdPendingVerificationWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId>
{
Data = [new TaxId
{
Verification = new TaxIdVerification
{
Status = TaxIdVerificationStatus.Pending
}
}]
},
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
TaxId.Type: "tax_id_pending_verification"
});
}
[Theory, BitAutoData]
public async Task Run_Has_TaxIdFailedVerificationWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId>
{
Data = [new TaxId
{
Verification = new TaxIdVerification
{
Status = TaxIdVerificationStatus.Unverified
}
}]
},
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
TaxId.Type: "tax_id_failed_verification"
});
}
[Theory, BitAutoData]
public async Task Run_TaxIdVerified_NoTaxIdWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId>
{
Data = [new TaxId
{
Verification = new TaxIdVerification
{
Status = TaxIdVerificationStatus.Verified
}
}]
},
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.Null(response!.TaxId);
}
[Theory, BitAutoData]
public async Task Run_MultipleRegistrations_MatchesCorrectCountry(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "DE" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Active))
.Returns(new StripeList<Registration>
{
Data = [
new Registration { Country = "US" },
new Registration { Country = "DE" },
new Registration { Country = "FR" }
]
});
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Scheduled))
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
TaxId.Type: "tax_id_missing"
});
}
[Theory, BitAutoData]
public async Task Run_CombinesBothWarningTypes(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = false;
var cancelAt = DateTime.UtcNow.AddDays(5);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Unpaid,
CancelAt = cancelAt,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "CA" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.True(response is
{
Suspension.Resolution: "add_payment_method",
TaxId.Type: "tax_id_missing"
});
Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt);
}
[Theory, BitAutoData]
public async Task Run_USCustomer_NoTaxIdWarning(
Provider provider,
SutProvider<GetProviderWarningsQuery> sutProvider)
{
provider.Enabled = true;
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(provider, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.SequenceEqual(_requiredExpansions)
))
.Returns(new Subscription
{
Status = SubscriptionStatus.Active,
Customer = new Customer
{
TaxIds = new StripeList<TaxId> { Data = [] },
Address = new Address { Country = "US" }
}
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]
});
var response = await sutProvider.Sut.Run(provider);
Assert.Null(response!.TaxId);
}
}

View File

@@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;

View File

@@ -1,8 +1,6 @@
using System.Globalization;
using System.Net;
using Bit.Commercial.Core.Billing.Providers.Models;
using Bit.Commercial.Core.Billing.Providers.Services;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
@@ -11,17 +9,16 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@@ -352,9 +349,6 @@ public class ProviderBillingServiceTests
CloudRegion = "US"
});
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
@@ -898,208 +892,97 @@ public class ProviderBillingServiceTests
#region SetupCustomer
[Theory, BitAutoData]
public async Task SetupCustomer_MissingCountry_ContactSupport(
public async Task SetupCustomer_NullPaymentMethod_ThrowsNullReferenceException(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
BillingAddress billingAddress)
{
taxInfo.BillingAddressCountry = null;
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo));
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
}
[Theory, BitAutoData]
public async Task SetupCustomer_MissingPostalCode_ContactSupport(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
{
taxInfo.BillingAddressCountry = null;
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo));
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.CustomerGetAsync(Arg.Any<string>(), Arg.Any<CustomerGetOptions>());
}
[Theory, BitAutoData]
public async Task SetupCustomer_NoPaymentMethod_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var expected = new Customer
{
Id = "customer_id",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
o.Address.City == taxInfo.BillingAddressCity &&
o.Address.State == taxInfo.BillingAddressState &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
o.Email == provider.BillingEmail &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
.Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo);
Assert.Equivalent(expected, actual);
}
[Theory, BitAutoData]
public async Task SetupCustomer_InvalidRequiredPaymentMethod_ThrowsBillingException(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo,
TokenizedPaymentSource tokenizedPaymentSource)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
await ThrowsBillingExceptionAsync(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
await Assert.ThrowsAsync<NullReferenceException>(() =>
sutProvider.Sut.SetupCustomer(provider, null, billingAddress));
}
[Theory, BitAutoData]
public async Task SetupCustomer_WithBankAccount_Error_Reverts(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
BillingAddress billingAddress)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" };
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([
new SetupIntent { Id = "setup_intent_id" }
]);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
o.Address.City == taxInfo.BillingAddressCity &&
o.Address.State == taxInfo.BillingAddressState &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
o.Address.Line2 == billingAddress.Line2 &&
o.Address.City == billingAddress.City &&
o.Address.State == billingAddress.State &&
o.Description == provider.DisplayBusinessName() &&
o.Email == provider.BillingEmail &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Throws<StripeException>();
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns("setup_intent_id");
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id");
await Assert.ThrowsAsync<StripeException>(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
options.CancellationReason == "abandoned"));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Remove(provider.Id);
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).RemoveSetupIntentForSubscriber(provider.Id);
}
[Theory, BitAutoData]
public async Task SetupCustomer_WithPayPal_Error_Reverts(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
BillingAddress billingAddress)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" };
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token)
.Returns("braintree_customer_id");
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
o.Address.City == taxInfo.BillingAddressCity &&
o.Address.State == taxInfo.BillingAddressState &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
o.Address.Line2 == billingAddress.Line2 &&
o.Address.City == billingAddress.City &&
o.Address.State == billingAddress.State &&
o.Description == provider.DisplayBusinessName() &&
o.Email == provider.BillingEmail &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.Metadata["region"] == "" &&
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Throws<StripeException>();
await Assert.ThrowsAsync<StripeException>(() =>
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
await sutProvider.GetDependency<IBraintreeGateway>().Customer.Received(1).DeleteAsync("braintree_customer_id");
}
@@ -1108,17 +991,11 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_WithBankAccount_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
BillingAddress billingAddress)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
@@ -1128,33 +1005,30 @@ public class ProviderBillingServiceTests
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" };
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([
new SetupIntent { Id = "setup_intent_id" }
]);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
o.Address.City == taxInfo.BillingAddressCity &&
o.Address.State == taxInfo.BillingAddressState &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
o.Address.Line2 == billingAddress.Line2 &&
o.Address.City == billingAddress.City &&
o.Address.State == billingAddress.State &&
o.Description == provider.DisplayBusinessName() &&
o.Email == provider.BillingEmail &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
Assert.Equivalent(expected, actual);
@@ -1165,17 +1039,11 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_WithPayPal_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
BillingAddress billingAddress)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
@@ -1185,32 +1053,29 @@ public class ProviderBillingServiceTests
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" };
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token)
.Returns("braintree_customer_id");
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
o.Address.City == taxInfo.BillingAddressCity &&
o.Address.State == taxInfo.BillingAddressState &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
o.Address.Line2 == billingAddress.Line2 &&
o.Address.City == billingAddress.City &&
o.Address.State == billingAddress.State &&
o.Description == provider.DisplayBusinessName() &&
o.Email == provider.BillingEmail &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.Metadata["region"] == "" &&
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
Assert.Equivalent(expected, actual);
}
@@ -1219,17 +1084,11 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_WithCard_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
BillingAddress billingAddress)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "12345678Z");
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
@@ -1239,30 +1098,26 @@ public class ProviderBillingServiceTests
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
o.Address.City == taxInfo.BillingAddressCity &&
o.Address.State == taxInfo.BillingAddressState &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
o.Address.Line2 == billingAddress.Line2 &&
o.Address.City == billingAddress.City &&
o.Address.State == billingAddress.State &&
o.Description == provider.DisplayBusinessName() &&
o.Email == provider.BillingEmail &&
o.PaymentMethod == tokenizedPaymentSource.Token &&
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value))
.Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
Assert.Equivalent(expected, actual);
}
@@ -1271,17 +1126,11 @@ public class ProviderBillingServiceTests
public async Task SetupCustomer_WithCard_ReverseCharge_Success(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
BillingAddress billingAddress)
{
provider.Name = "MSP";
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
taxInfo.BillingAddressCountry = "AD";
billingAddress.Country = "FR"; // Non-US country to trigger reverse charge
billingAddress.TaxId = new TaxID("fr_siren", "123456789");
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
@@ -1291,59 +1140,51 @@ public class ProviderBillingServiceTests
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == taxInfo.BillingAddressCountry &&
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
o.Address.City == taxInfo.BillingAddressCity &&
o.Address.State == taxInfo.BillingAddressState &&
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
o.Address.Line2 == billingAddress.Line2 &&
o.Address.City == billingAddress.City &&
o.Address.State == billingAddress.State &&
o.Description == provider.DisplayBusinessName() &&
o.Email == provider.BillingEmail &&
o.PaymentMethod == tokenizedPaymentSource.Token &&
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() &&
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() &&
o.Metadata["region"] == "" &&
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber &&
o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code &&
o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value &&
o.TaxExempt == StripeConstants.TaxExempt.Reverse))
.Returns(expected);
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress);
Assert.Equivalent(expected, actual);
}
[Theory, BitAutoData]
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid(
public async Task SetupCustomer_WithInvalidTaxId_ThrowsBadRequestException(
SutProvider<ProviderBillingService> sutProvider,
Provider provider,
TaxInfo taxInfo)
BillingAddress billingAddress)
{
provider.Name = "MSP";
billingAddress.Country = "AD";
billingAddress.TaxId = new TaxID("es_nif", "invalid_tax_id");
taxInfo.BillingAddressCountry = "AD";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
sutProvider.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(
p => p == taxInfo.BillingAddressCountry),
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns((string)null);
stripeAdapter.CustomerCreateAsync(Arg.Any<CustomerCreateOptions>())
.Throws(new StripeException("Invalid tax ID") { StripeError = new StripeError { Code = "tax_id_invalid" } });
var actual = await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.SetupCustomer(provider, taxInfo));
await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress));
Assert.IsType<BadRequestException>(actual);
Assert.Equal("billingTaxIdTypeInferenceError", actual.Message);
Assert.Equal("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.", actual.Message);
}
#endregion
@@ -1616,8 +1457,6 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
@@ -1694,12 +1533,10 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
const string setupIntentId = "seti_123";
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns(setupIntentId);
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
options.Expand.Contains("payment_method"))).Returns(new SetupIntent
@@ -1797,8 +1634,6 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
@@ -1877,11 +1712,6 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>

View File

@@ -1,7 +1,7 @@
using Bit.Commercial.Core.Billing.Providers.Services;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Providers.Services;
using Stripe;
using Xunit;

View File

@@ -295,7 +295,7 @@ public class ProjectServiceAccountsAccessPoliciesAuthorizationHandlerTests
{
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(resource.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default, resource.OrganizationId)
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default!, resource.OrganizationId)
.ReturnsForAnyArgs((accessClientType, userId));
}

View File

@@ -247,7 +247,7 @@ public class ServiceAccountGrantedPoliciesAuthorizationHandlerTests
{
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(resource.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default, resource.OrganizationId)
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default!, resource.OrganizationId)
.ReturnsForAnyArgs((accessClientType, userId));
}

View File

@@ -207,7 +207,7 @@ public class BulkSecretAuthorizationHandlerTests
{
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(organizationId)
.Returns(true);
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default, organizationId)
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default!, organizationId)
.ReturnsForAnyArgs((accessClientType, userId));
}

View File

@@ -1,10 +1,10 @@
using System.Text.Json;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Models;
using Bit.Scim.Users;
using Bit.Scim.Utilities;
@@ -101,7 +101,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationService>().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM);
await sutProvider.GetDependency<IRevokeOrganizationUserCommand>().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM);
}
[Theory]
@@ -129,7 +129,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IOrganizationService>().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM);
await sutProvider.GetDependency<IRevokeOrganizationUserCommand>().Received(1).RevokeUserAsync(organizationUser, EventSystemUser.SCIM);
}
[Theory]
@@ -149,7 +149,7 @@ public class PatchUserCommandTests
await sutProvider.Sut.PatchUserAsync(organizationUser.OrganizationId, organizationUser.Id, scimPatchModel);
await sutProvider.GetDependency<IRestoreOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RestoreUserAsync(default, EventSystemUser.SCIM);
await sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM);
await sutProvider.GetDependency<IRevokeOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RevokeUserAsync(default, EventSystemUser.SCIM);
}
[Theory]

View File

@@ -53,6 +53,7 @@ services:
- ./.data/postgres/log:/var/log/postgresql
profiles:
- postgres
- ef
mysql:
image: mysql:8.0
@@ -69,6 +70,7 @@ services:
- mysql_dev_data:/var/lib/mysql
profiles:
- mysql
- ef
mariadb:
image: mariadb:10
@@ -76,13 +78,13 @@ services:
- 4306:3306
environment:
MARIADB_USER: maria
MARIADB_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_DATABASE: vault_dev
MARIADB_RANDOM_ROOT_PASSWORD: "true"
volumes:
- mariadb_dev_data:/var/lib/mysql
profiles:
- mariadb
- ef
idp:
image: kenchan0130/simplesamlphp:1.19.8
@@ -99,7 +101,7 @@ services:
- idp
rabbitmq:
image: rabbitmq:4.1.0-management
image: rabbitmq:4.1.3-management
container_name: rabbitmq
ports:
- "5672:5672"
@@ -153,5 +155,6 @@ volumes:
mssql_dev_data:
postgres_dev_data:
mysql_dev_data:
mariadb_dev_data:
rabbitmq_data:
redis_data:

View File

@@ -0,0 +1,28 @@
Set-Location "$PSScriptRoot/.."
$env:ASPNETCORE_ENVIRONMENT = "Development"
$env:swaggerGen = "True"
$env:DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX = "2"
$env:GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING = "placeholder"
dotnet tool restore
# Identity
Set-Location "./src/Identity"
dotnet build
dotnet swagger tofile --output "../../identity.json" --host "https://identity.bitwarden.com" "./bin/Debug/net8.0/Identity.dll" "v1"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
# Api internal & public
Set-Location "../../src/Api"
dotnet build
dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}

View File

@@ -70,7 +70,7 @@ Foreach ($item in @(
@($mysql, "MySQL", "MySqlMigrations", "mySql", 2),
# MariaDB shares the MySQL connection string in the server config so they are mutually exclusive in that context.
# However they can still be run independently for integration tests.
@($mariadb, "MariaDB", "MySqlMigrations", "mySql", 3)
@($mariadb, "MariaDB", "MySqlMigrations", "mySql", 4)
)) {
if (!$item[0] -and !$all) {
continue

View File

@@ -33,6 +33,8 @@
"id": "<your Installation Id>",
"key": "<your Installation Key>"
},
"licenseDirectory": "<full path to license directory>"
"licenseDirectory": "<full path to license directory>",
"enableNewDeviceVerification": true,
"enableEmailVerification": true
}
}

View File

@@ -31,6 +31,12 @@
},
{
"Name": "events-webhook-subscription"
},
{
"Name": "events-hec-subscription"
},
{
"Name": "events-datadog-subscription"
}
]
},
@@ -64,6 +70,34 @@
}
}
]
},
{
"Name": "integration-hec-subscription",
"Rules": [
{
"Name": "hec-integration-filter",
"Properties": {
"FilterType": "Correlation",
"CorrelationFilter": {
"Label": "hec"
}
}
}
]
},
{
"Name": "integration-datadog-subscription",
"Rules": [
{
"Name": "datadog-integration-filter",
"Properties": {
"FilterType": "Correlation",
"CorrelationFilter": {
"Label": "datadog"
}
}
}
]
}
]
}

View File

@@ -20,7 +20,7 @@ public class StaticClientStoreTests
[Benchmark]
public Client? TryGetValue()
{
return _store.ApiClients.TryGetValue(ClientId, out var client)
return _store.Clients.TryGetValue(ClientId, out var client)
? client
: null;
}

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet" Version="0.15.3" />
</ItemGroup>
<ItemGroup>

View File

@@ -9,12 +9,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL;
const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
export const options = {
ext: {
loadimpact: {
projectID: 3639465,
name: "Config",
},
},
scenarios: {
constant_load: {
executor: "constant-arrival-rate",

View File

@@ -10,12 +10,6 @@ const AUTH_CLIENT_ID = __ENV.AUTH_CLIENT_ID;
const AUTH_CLIENT_SECRET = __ENV.AUTH_CLIENT_SECRET;
export const options = {
ext: {
loadimpact: {
projectID: 3639465,
name: "Groups",
},
},
scenarios: {
constant_load: {
executor: "constant-arrival-rate",

View File

@@ -6,12 +6,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL;
const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
export const options = {
ext: {
loadimpact: {
projectID: 3639465,
name: "Login",
},
},
scenarios: {
constant_load: {
executor: "constant-arrival-rate",

View File

@@ -9,12 +9,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL;
const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
export const options = {
ext: {
loadimpact: {
projectID: 3639465,
name: "Sync",
},
},
scenarios: {
constant_load: {
executor: "constant-arrival-rate",

View File

@@ -1,4 +1,7 @@
using System.Net;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Net;
using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums;
using Bit.Admin.Services;
@@ -6,6 +9,7 @@ using Bit.Admin.Utilities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
@@ -29,7 +33,6 @@ namespace Bit.Admin.AdminConsole.Controllers;
[Authorize]
public class OrganizationsController : Controller
{
private readonly IOrganizationService _organizationService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationConnectionRepository _organizationConnectionRepository;
@@ -52,9 +55,9 @@ public class OrganizationsController : Controller
private readonly IProviderBillingService _providerBillingService;
private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand;
private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
public OrganizationsController(
IOrganizationService organizationService,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationConnectionRepository organizationConnectionRepository,
@@ -76,9 +79,9 @@ public class OrganizationsController : Controller
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IProviderBillingService providerBillingService,
IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand,
IPricingClient pricingClient)
IPricingClient pricingClient,
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
{
_organizationService = organizationService;
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_organizationConnectionRepository = organizationConnectionRepository;
@@ -101,6 +104,7 @@ public class OrganizationsController : Controller
_providerBillingService = providerBillingService;
_organizationInitiateDeleteCommand = organizationInitiateDeleteCommand;
_pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
}
[RequirePermission(Permission.Org_List_View)]
@@ -392,7 +396,7 @@ public class OrganizationsController : Controller
var organizationUsers = await _organizationUserRepository.GetManyByOrganizationAsync(id, OrganizationUserType.Owner);
foreach (var organizationUser in organizationUsers)
{
await _organizationService.ResendInviteAsync(id, null, organizationUser.Id, true);
await _resendOrganizationInviteCommand.ResendInviteAsync(id, null, organizationUser.Id, true);
}
return Json(null);

View File

@@ -1,9 +1,12 @@
using System.ComponentModel.DataAnnotations;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.Net;
using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums;
using Bit.Admin.Services;
using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
@@ -18,6 +21,7 @@ using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@@ -34,26 +38,26 @@ namespace Bit.Admin.AdminConsole.Controllers;
[SelfHosted(NotSelfHostedOnly = true)]
public class ProvidersController : Controller
{
private readonly string _stripeUrl;
private readonly string _braintreeMerchantUrl;
private readonly string _braintreeMerchantId;
private readonly IOrganizationRepository _organizationRepository;
private readonly IResellerClientOrganizationSignUpCommand _resellerClientOrganizationSignUpCommand;
private readonly IProviderRepository _providerRepository;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IProviderService _providerService;
private readonly GlobalSettings _globalSettings;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IProviderService _providerService;
private readonly ICreateProviderCommand _createProviderCommand;
private readonly IFeatureService _featureService;
private readonly IProviderPlanRepository _providerPlanRepository;
private readonly IProviderBillingService _providerBillingService;
private readonly IPricingClient _pricingClient;
private readonly IStripeAdapter _stripeAdapter;
private readonly string _stripeUrl;
private readonly string _braintreeMerchantUrl;
private readonly string _braintreeMerchantId;
private readonly IAccessControlService _accessControlService;
private readonly ISubscriberService _subscriberService;
public ProvidersController(
IOrganizationRepository organizationRepository,
public ProvidersController(IOrganizationRepository organizationRepository,
IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository,
@@ -62,12 +66,13 @@ public class ProvidersController : Controller
GlobalSettings globalSettings,
IApplicationCacheService applicationCacheService,
ICreateProviderCommand createProviderCommand,
IFeatureService featureService,
IProviderPlanRepository providerPlanRepository,
IProviderBillingService providerBillingService,
IWebHostEnvironment webHostEnvironment,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter)
IStripeAdapter stripeAdapter,
IAccessControlService accessControlService,
ISubscriberService subscriberService)
{
_organizationRepository = organizationRepository;
_resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
@@ -78,14 +83,15 @@ public class ProvidersController : Controller
_globalSettings = globalSettings;
_applicationCacheService = applicationCacheService;
_createProviderCommand = createProviderCommand;
_featureService = featureService;
_providerPlanRepository = providerPlanRepository;
_providerBillingService = providerBillingService;
_pricingClient = pricingClient;
_stripeAdapter = stripeAdapter;
_accessControlService = accessControlService;
_stripeUrl = webHostEnvironment.GetStripeUrl();
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
_subscriberService = subscriberService;
}
[RequirePermission(Permission.Provider_List_View)]
@@ -288,9 +294,31 @@ public class ProvidersController : Controller
return View(oldModel);
}
var originalProviderStatus = provider.Enabled;
model.ToProvider(provider);
await _providerRepository.ReplaceAsync(provider);
// validate the stripe ids to prevent saving a bad one
if (provider.IsBillable())
{
if (!await _subscriberService.IsValidGatewayCustomerIdAsync(provider))
{
var oldModel = await GetEditModel(id);
ModelState.AddModelError(nameof(model.GatewayCustomerId), $"Invalid Gateway Customer Id: {model.GatewayCustomerId}");
return View(oldModel);
}
if (!await _subscriberService.IsValidGatewaySubscriptionIdAsync(provider))
{
var oldModel = await GetEditModel(id);
ModelState.AddModelError(nameof(model.GatewaySubscriptionId), $"Invalid Gateway Subscription Id: {model.GatewaySubscriptionId}");
return View(oldModel);
}
}
provider.Enabled = _accessControlService.UserHasPermission(Permission.Provider_CheckEnabledBox)
? model.Enabled : originalProviderStatus;
await _providerService.UpdateAsync(provider);
await _applicationCacheService.UpsertProviderAbilityAsync(provider);
if (!provider.IsBillable())
@@ -311,10 +339,7 @@ public class ProvidersController : Controller
]);
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
if (_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically))
{
var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId);
if (model.PayByInvoice != customer.ApprovedToPayByInvoice())
{
var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0";
@@ -326,7 +351,6 @@ public class ProvidersController : Controller
}
});
}
}
break;
case ProviderType.BusinessUnit:
{
@@ -370,10 +394,7 @@ public class ProvidersController : Controller
}
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
var payByInvoice =
_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) &&
(await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice();
var payByInvoice = ((await _subscriberService.GetCustomer(provider))?.ApprovedToPayByInvoice() ?? false);
return new ProviderEditModel(
provider, users, providerOrganizations,

View File

@@ -1,4 +1,7 @@
using System.ComponentModel.DataAnnotations;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;

View File

@@ -1,4 +1,7 @@
using System.ComponentModel.DataAnnotations;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities;

View File

@@ -1,4 +1,7 @@
using System.ComponentModel.DataAnnotations;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities;

View File

@@ -1,4 +1,7 @@
using System.ComponentModel.DataAnnotations;
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.Net;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;

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