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, "isRoot": true,
"tools": { "tools": {
"swashbuckle.aspnetcore.cli": { "swashbuckle.aspnetcore.cli": {
"version": "7.3.2", "version": "9.0.4",
"commands": ["swagger"] "commands": ["swagger"]
}, },
"dotnet-ef": { "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 # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
## Docker files have shared ownership ## ## Docker-related files
**/Dockerfile **/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
**/*.Dockerfile **/*.Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
**/.dockerignore **/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh **/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
## BRE team owns these workflows ## ## BRE team owns these workflows ##
.github/workflows/publish.yml @bitwarden/dept-bre .github/workflows/publish.yml @bitwarden/dept-bre
## These are shared workflows ## ## These are shared workflows ##
.github/workflows/_move_finalization_db_scripts.yml .github/workflows/_move_edd_db_scripts.yml
.github/workflows/release.yml .github/workflows/release.yml
# Database Operations for database changes # Database Operations for database changes
@@ -33,6 +34,9 @@ util/SqliteMigrations/** @bitwarden/dept-dbops
# Shared util projects # Shared util projects
util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev 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 team
**/Auth @bitwarden/team-auth-dev **/Auth @bitwarden/team-auth-dev
bitwarden_license/src/Sso @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 **/Tools @bitwarden/team-tools-dev
# Dirt (Data Insights & Reporting) team # Dirt (Data Insights & Reporting) team
src/Api/Controllers/Dirt @bitwarden/team-data-insights-and-reporting-dev **/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
# Vault team # Vault team
**/Vault @bitwarden/team-vault-dev **/Vault @bitwarden/team-vault-dev
@@ -93,6 +93,8 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
**/.dockerignore @bitwarden/team-platform-dev **/.dockerignore @bitwarden/team-platform-dev
**/Dockerfile @bitwarden/team-platform-dev **/Dockerfile @bitwarden/team-platform-dev
**/entrypoint.sh @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) # Multiple owners - DO NOT REMOVE (BRE)
**/packages.lock.json **/packages.lock.json

View File

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

View File

@@ -9,18 +9,6 @@
"nuget", "nuget",
], ],
packageRules: [ 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", groupName: "dockerfile minor",
matchManagers: ["dockerfile"], matchManagers: ["dockerfile"],
@@ -35,6 +23,7 @@
groupName: "github-action minor", groupName: "github-action minor",
matchManagers: ["github-actions"], matchManagers: ["github-actions"],
matchUpdateTypes: ["minor"], matchUpdateTypes: ["minor"],
addLabels: ["hold"],
}, },
{ {
// For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates. // For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates.
@@ -95,7 +84,6 @@
"Serilog.AspNetCore", "Serilog.AspNetCore",
"Serilog.Extensions.Logging", "Serilog.Extensions.Logging",
"Serilog.Extensions.Logging.File", "Serilog.Extensions.Logging.File",
"Serilog.Sinks.AzureCosmosDB",
"Serilog.Sinks.SyslogMessages", "Serilog.Sinks.SyslogMessages",
"Stripe.net", "Stripe.net",
"Swashbuckle.AspNetCore", "Swashbuckle.AspNetCore",

View File

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

View File

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

View File

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

View File

@@ -11,11 +11,15 @@ jobs:
build-docker: build-docker:
name: Remove branch-specific Docker images name: Remove branch-specific Docker images
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions:
id-token: write
steps: steps:
- name: Log in to Azure - production subscription - name: Log in to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: bitwarden/gh-actions/azure-login@main
with: 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 - name: Log in to Azure ACR
run: az acr login -n $_AZ_REGISTRY --only-show-errors run: az acr login -n $_AZ_REGISTRY --only-show-errors
@@ -62,3 +66,6 @@ jobs:
- name: Log out of Docker - name: Log out of Docker
run: docker logout run: docker logout
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main

View File

@@ -9,11 +9,16 @@ jobs:
delete-rc: delete-rc:
name: Delete RC Branch name: Delete RC Branch
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
permissions:
contents: write
id-token: write
steps: steps:
- name: Login to Azure - CI Subscription - name: Log in to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: bitwarden/gh-actions/azure-login@main
with: 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 - name: Retrieve bot secrets
id: retrieve-bot-secrets id: retrieve-bot-secrets
@@ -22,6 +27,9 @@ jobs:
keyvault: bitwarden-ci keyvault: bitwarden-ci
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Checkout main - name: Checkout main
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:

View File

@@ -1,25 +1,24 @@
name: Collect code references name: Collect code references
on: on:
push: push:
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
check-ld-secret: check-secret-access:
name: Check for LD secret name: Check for secret access
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
outputs: outputs:
available: ${{ steps.check-ld-secret.outputs.available }} available: ${{ steps.check-secret-access.outputs.available }}
permissions: permissions: {}
contents: read
steps: steps:
- name: Check - name: Check
id: check-ld-secret id: check-secret-access
run: | run: |
if [ "${{ secrets.LD_ACCESS_TOKEN }}" != '' ]; then if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT; echo "available=true" >> $GITHUB_OUTPUT;
else else
echo "available=false" >> $GITHUB_OUTPUT; echo "available=false" >> $GITHUB_OUTPUT;
@@ -28,21 +27,39 @@ jobs:
refs: refs:
name: Code reference collection name: Code reference collection
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: check-ld-secret needs: check-secret-access
if: ${{ needs.check-ld-secret.outputs.available == 'true' }} if: ${{ needs.check-secret-access.outputs.available == 'true' }}
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
id-token: write
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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 - name: Collect
id: collect id: collect
uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0 uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0
with: with:
accessToken: ${{ secrets.LD_ACCESS_TOKEN }} accessToken: ${{ steps.get-kv-secrets.outputs.LD-ACCESS-TOKEN }}
projKey: default projKey: default
allowTags: true allowTags: true

View File

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

View File

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

View File

@@ -22,7 +22,9 @@ on:
required: false required: false
type: string type: string
permissions: {} permissions:
pull-requests: write
contents: write
jobs: jobs:
setup: setup:
@@ -54,7 +56,27 @@ jobs:
- setup - setup
outputs: outputs:
version: ${{ steps.set-final-version-output.outputs.version }} version: ${{ steps.set-final-version-output.outputs.version }}
permissions:
id-token: write
steps: 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 - name: Validate version input format
if: ${{ inputs.version_number_override != '' }} if: ${{ inputs.version_number_override != '' }}
uses: bitwarden/gh-actions/version-check@main uses: bitwarden/gh-actions/version-check@main
@@ -62,11 +84,11 @@ jobs:
version: ${{ inputs.version_number_override }} version: ${{ inputs.version_number_override }}
- name: Generate GH App token - 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 id: app-token
with: with:
app-id: ${{ secrets.BW_GHAPP_ID }} app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Check out branch - name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -158,13 +180,33 @@ jobs:
- setup - setup
- bump_version - bump_version
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
id-token: write
steps: 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 - 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 id: app-token
with: with:
app-id: ${{ secrets.BW_GHAPP_ID }} app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Check out target ref - name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -188,8 +230,13 @@ jobs:
git switch --quiet --create $BRANCH_NAME git switch --quiet --create $BRANCH_NAME
git push --quiet --set-upstream origin $BRANCH_NAME git push --quiet --set-upstream origin $BRANCH_NAME
move_future_db_scripts: move_edd_db_scripts:
name: Move finalization database scripts name: Move EDD database scripts
needs: cut_branch 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 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: branches:
- "main" - "main"
permissions: {}
jobs: jobs:
check-run: check-run:
name: Check PR run name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
permissions:
contents: read
sast: sast:
name: SAST scan name: Checkmarx
runs-on: ubuntu-22.04 uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main
needs: check-run 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: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
security-events: write security-events: write
id-token: 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 }}
quality: quality:
name: Quality scan name: Sonar
runs-on: ubuntu-22.04 uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main
needs: check-run 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: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
id-token: write
steps: with:
- name: Set up JDK 17 sonar-config: "dotnet"
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
with:
java-version: 17
distribution: "zulu"
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Install SonarCloud scanner
run: dotnet tool install dotnet-sonarscanner -g
- name: Scan with SonarCloud
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" \
/d:sonar.test.inclusions=test/,bitwarden_license/test/ \
/d:sonar.exclusions=test/,bitwarden_license/test/ \
/o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \
/d:sonar.host.url="https://sonarcloud.io" ${{ contains(github.event_name, 'pull_request') && format('/d:sonar.pullrequest.key={0}', github.event.pull_request.number) || '' }}
dotnet build
dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"

View File

@@ -47,7 +47,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Restore tools - name: Restore tools
run: dotnet tool restore run: dotnet tool restore
@@ -154,7 +154,7 @@ jobs:
run: 'docker logs $(docker ps --quiet --filter "name=mssql")' run: 'docker logs $(docker ps --quiet --filter "name=mssql")'
- name: Report test results - 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() }} if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with: with:
name: Test Results name: Test Results
@@ -163,7 +163,7 @@ jobs:
fail-on-error: true fail-on-error: true
- name: Upload to codecov.io - 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 - name: Docker Compose down
if: always() if: always()
@@ -179,7 +179,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Print environment - name: Print environment
run: | run: |
@@ -229,11 +229,27 @@ jobs:
- name: Validate XML - name: Validate XML
run: | run: |
if grep -q "<Operations>" "report.xml"; then if grep -q "<Operations>" "report.xml"; then
echo echo "ERROR: Migration files are not in sync with the SQL project"
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 ""
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 exit 1
else else
echo "Report looks good" echo "SUCCESS: Database validation passed"
fi fi
shell: bash shell: bash

View File

@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0 uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Print environment - name: Print environment
run: | 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" 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 - 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() }} if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with: with:
name: Test Results name: Test Results
@@ -58,4 +58,4 @@ jobs:
fail-on-error: true fail-on-error: true
- name: Upload to codecov.io - name: Upload to codecov.io
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2 uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3

12
.gitignore vendored
View File

@@ -129,7 +129,7 @@ publish/
# Publish Web Output # Publish Web Output
*.[Pp]ublish.xml *.[Pp]ublish.xml
*.azurePubxml *.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings # TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted # but database connection strings (with potential passwords) will be unencrypted
*.pubxml *.pubxml
*.publishproj *.publishproj
@@ -214,6 +214,9 @@ bitwarden_license/src/Sso/wwwroot/assets
.idea/* .idea/*
**/**.swp **/**.swp
.mono .mono
src/Core/MailTemplates/Mjml/out
NativeMethods.g.cs
util/RustSdk/rust/target
src/Admin/Admin.zip src/Admin/Admin.zip
src/Api/Api.zip src/Api/Api.zip
@@ -225,5 +228,8 @@ src/Notifications/Notifications.zip
bitwarden_license/src/Portal/Portal.zip bitwarden_license/src/Portal/Portal.zip
bitwarden_license/src/Sso/Sso.zip bitwarden_license/src/Sso/Sso.zip
**/src/**/flags.json **/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> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2025.6.2</Version> <Version>2025.10.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion> <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> <IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject>
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable> <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> <TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
</PropertyGroup> </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> <PropertyGroup>
<!--
NuGet: https://www.nuget.org/packages/Microsoft.NET.Test.Sdk
-->
<MicrosoftNetTestSdkVersion>17.8.0</MicrosoftNetTestSdkVersion> <MicrosoftNetTestSdkVersion>17.8.0</MicrosoftNetTestSdkVersion>
<!--
NuGet: https://www.nuget.org/packages/xunit
-->
<XUnitVersion>2.6.6</XUnitVersion> <XUnitVersion>2.6.6</XUnitVersion>
<!--
NuGet: https://www.nuget.org/packages/xunit.runner.visualstudio
-->
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion> <XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
<!--
NuGet: https://www.nuget.org/packages/coverlet.collector
-->
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion> <CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
<!--
NuGet: https://www.nuget.org/packages/NSubstitute
-->
<NSubstituteVersion>5.1.0</NSubstituteVersion> <NSubstituteVersion>5.1.0</NSubstituteVersion>
<!--
NuGet: https://www.nuget.org/packages/AutoFixture.Xunit2
-->
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version> <AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
<!--
NuGet: https://www.nuget.org/packages/AutoFixture.AutoNSubstitute
-->
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion> <AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
</PropertyGroup> </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"> <Target Name="SetSourceRevisionId" BeforeTargets="CoreGenerateAssemblyInfo">
<Exec Command="git describe --long --always --dirty --exclude=* --abbrev=8" ConsoleToMSBuild="True" IgnoreExitCode="False"> <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> </Exec>
</Target> </Target>
<Target Name="WriteRevision" AfterTargets="SetSourceRevisionId"> <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}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}" 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 EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution 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}.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.ActiveCfg = Release|Any CPU
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -398,6 +403,7 @@ Global
{9A612EBA-1C0E-42B8-982B-62F0EE81000A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {9A612EBA-1C0E-42B8-982B-62F0EE81000A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {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} {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} 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;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; 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 }] Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
}; };
var setNonUSBusinessUseToReverseCharge = _featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
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); var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id; organization.GatewaySubscriptionId = subscription.Id;
organization.Status = OrganizationStatusType.Created; organization.Status = OrganizationStatusType.Created;
organization.Enabled = true;
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0); 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;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; 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.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums; 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.Pricing;
using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context; using Bit.Core.Context;
@@ -87,7 +90,7 @@ public class ProviderService : IProviderService
_providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand; _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); var owner = await _userService.GetUserByIdAsync(ownerUserId);
if (owner == null) if (owner == null)
@@ -112,24 +115,7 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid owner."); throw new BadRequestException("Invalid owner.");
} }
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode)) var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress);
{
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);
provider.GatewayCustomerId = customer.Id; provider.GatewayCustomerId = customer.Id;
var subscription = await _providerBillingService.SetupSubscription(provider); var subscription = await _providerBillingService.SetupSubscription(provider);
provider.GatewaySubscriptionId = subscription.Id; provider.GatewaySubscriptionId = subscription.Id;
@@ -149,7 +135,15 @@ public class ProviderService : IProviderService
throw new ArgumentException("Cannot create provider this way."); 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); 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) public async Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite)
@@ -725,4 +719,20 @@ public class ProviderService : IProviderService
throw new BadRequestException($"Unsupported provider type {providerType}."); 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 Bit.Core.Billing.Providers.Entities;
using CsvHelper.Configuration.Attributes; 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.Commercial.Core.Billing.Providers.Models;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
@@ -11,6 +14,7 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Models; 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.Providers.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
@@ -35,10 +37,12 @@ using Subscription = Stripe.Subscription;
namespace Bit.Commercial.Core.Billing.Providers.Services; namespace Bit.Commercial.Core.Billing.Providers.Services;
using static Constants;
using static StripeConstants;
public class ProviderBillingService( public class ProviderBillingService(
IBraintreeGateway braintreeGateway, IBraintreeGateway braintreeGateway,
IEventService eventService, IEventService eventService,
IFeatureService featureService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger, ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@@ -49,8 +53,7 @@ public class ProviderBillingService(
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService, ISubscriberService subscriberService)
ITaxService taxService)
: IProviderBillingService : IProviderBillingService
{ {
public async Task AddExistingOrganization( public async Task AddExistingOrganization(
@@ -59,10 +62,7 @@ public class ProviderBillingService(
string key) string key)
{ {
await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
new SubscriptionUpdateOptions new SubscriptionUpdateOptions { CancelAtPeriodEnd = false });
{
CancelAtPeriodEnd = false
});
var subscription = var subscription =
await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId, await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
@@ -81,7 +81,7 @@ public class ProviderBillingService(
var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now; 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, await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId,
new InvoiceFinalizeOptions { AutoAdvance = true }); new InvoiceFinalizeOptions { AutoAdvance = true });
@@ -182,16 +182,8 @@ public class ProviderBillingService(
{ {
Items = Items =
[ [
new SubscriptionItemOptions new SubscriptionItemOptions { Price = newPriceId, Quantity = oldSubscriptionItem!.Quantity },
{ new SubscriptionItemOptions { Id = oldSubscriptionItem.Id, Deleted = true }
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) // Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan // 1. Retrieve PlanType and PlanName for ProviderPlan
// 2. Assign PlanType & PlanName to Organization // 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); var newPlan = await pricingClient.GetPlanOrThrow(newPlanType);
@@ -211,6 +204,7 @@ public class ProviderBillingService(
{ {
throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
} }
organization.PlanType = newPlanType; organization.PlanType = newPlanType;
organization.Plan = newPlan.Name; organization.Plan = newPlan.Name;
await organizationRepository.ReplaceAsync(organization); await organizationRepository.ReplaceAsync(organization);
@@ -226,15 +220,15 @@ public class ProviderBillingService(
if (!string.IsNullOrEmpty(organization.GatewayCustomerId)) 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; return;
} }
var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions var providerCustomer =
{ await subscriberService.GetCustomerOrThrow(provider,
Expand = ["tax", "tax_ids"] new CustomerGetOptions { Expand = ["tax", "tax_ids"] });
});
var providerTaxId = providerCustomer.TaxIds.FirstOrDefault(); var providerTaxId = providerCustomer.TaxIds.FirstOrDefault();
@@ -267,25 +261,18 @@ public class ProviderBillingService(
} }
] ]
}, },
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string> { { "region", globalSettings.BaseServiceUri.CloudRegion } },
{ TaxIdData = providerTaxId == null
{ "region", globalSettings.BaseServiceUri.CloudRegion } ? null
}, :
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 (providerCustomer.Address is not { Country: CountryAbbreviations.UnitedStates })
if (setNonUSBusinessUseToReverseCharge && providerCustomer.Address is not { Country: "US" })
{ {
customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; customerCreateOptions.TaxExempt = TaxExempt.Reverse;
} }
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
@@ -347,9 +334,9 @@ public class ProviderBillingService(
.Where(pair => pair.subscription is .Where(pair => pair.subscription is
{ {
Status: Status:
StripeConstants.SubscriptionStatus.Active or SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.Trialing or SubscriptionStatus.Trialing or
StripeConstants.SubscriptionStatus.PastDue SubscriptionStatus.PastDue
}).ToList(); }).ToList();
if (active.Count == 0) if (active.Count == 0)
@@ -474,35 +461,27 @@ public class ProviderBillingService(
// Below the limit to above the limit // Below the limit to above the limit
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) || (currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
// Above the limit to further above the limit // 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( public async Task<Customer> SetupCustomer(
Provider provider, Provider provider,
TaxInfo taxInfo, TokenizedPaymentMethod paymentMethod,
TokenizedPaymentSource tokenizedPaymentSource = null) 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 var options = new CustomerCreateOptions
{ {
Address = new AddressOptions Address = new AddressOptions
{ {
Country = taxInfo.BillingAddressCountry, Country = billingAddress.Country,
PostalCode = taxInfo.BillingAddressPostalCode, PostalCode = billingAddress.PostalCode,
Line1 = taxInfo.BillingAddressLine1, Line1 = billingAddress.Line1,
Line2 = taxInfo.BillingAddressLine2, Line2 = billingAddress.Line2,
City = taxInfo.BillingAddressCity, City = billingAddress.City,
State = taxInfo.BillingAddressState State = billingAddress.State
}, },
Coupon = !string.IsNullOrEmpty(provider.DiscountId) ? provider.DiscountId : null,
Description = provider.DisplayBusinessName(), Description = provider.DisplayBusinessName(),
Email = provider.BillingEmail, Email = provider.BillingEmail,
InvoiceSettings = new CustomerInvoiceSettingsOptions InvoiceSettings = new CustomerInvoiceSettingsOptions
@@ -518,112 +497,71 @@ public class ProviderBillingService(
} }
] ]
}, },
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string> { { "region", globalSettings.BaseServiceUri.CloudRegion } },
{ TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None
{ "region", globalSettings.BaseServiceUri.CloudRegion }
}
}; };
var setNonUSBusinessUseToReverseCharge = featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge); if (billingAddress.TaxId != null)
if (setNonUSBusinessUseToReverseCharge && taxInfo.BillingAddressCountry != "US")
{ {
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 = 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 options.TaxIdData.Add(new CustomerTaxIdDataOptions
{ {
Type = StripeConstants.TaxIdType.EUVAT, Type = TaxIdType.EUVAT,
Value = $"ES{taxInfo.TaxIdNumber}" Value = $"ES{billingAddress.TaxId.Value}"
}); });
} }
} }
if (!string.IsNullOrEmpty(provider.DiscountId))
{
options.Coupon = provider.DiscountId;
}
var requireProviderPaymentMethodDuringSetup =
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
var braintreeCustomerId = ""; var braintreeCustomerId = "";
if (requireProviderPaymentMethodDuringSetup) // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (paymentMethod.Type)
{ {
if (tokenizedPaymentSource is not case TokenizablePaymentMethodType.BankAccount:
{ {
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, var setupIntent =
Token: not null and not "" (await stripeAdapter.SetupIntentList(new SetupIntentListOptions
})
{
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)
{
case PaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
.FirstOrDefault();
if (setupIntent == null)
{ {
logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id); PaymentMethod = paymentMethod.Token
throw new BillingException(); }))
} .FirstOrDefault();
await setupIntentCache.Set(provider.Id, setupIntent.Id); if (setupIntent == null)
break;
}
case PaymentMethodType.Card:
{ {
options.PaymentMethod = token; logger.LogError(
options.InvoiceSettings.DefaultPaymentMethod = token; "Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account",
break; provider.Id);
throw new BillingException();
} }
case PaymentMethodType.PayPal:
{ await setupIntentCache.Set(provider.Id, setupIntent.Id);
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token); break;
options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; }
break; case TokenizablePaymentMethodType.Card:
} {
} options.PaymentMethod = paymentMethod.Token;
options.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token;
break;
}
case TokenizablePaymentMethodType.PayPal:
{
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, paymentMethod.Token);
options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break;
}
} }
try try
{ {
return await stripeAdapter.CustomerCreateAsync(options); return await stripeAdapter.CustomerCreateAsync(options);
} }
catch (StripeException stripeException) when (stripeException.StripeError?.Code == catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid)
StripeConstants.ErrorCodes.TaxIdInvalid)
{ {
await Revert(); await Revert();
throw new BadRequestException( throw new BadRequestException(
@@ -637,25 +575,22 @@ public class ProviderBillingService(
async Task Revert() async Task Revert()
{ {
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource != null) // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (paymentMethod.Type)
{ {
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault case TokenizablePaymentMethodType.BankAccount:
switch (tokenizedPaymentSource.Type) {
{ var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
case PaymentMethodType.BankAccount: await stripeAdapter.SetupIntentCancel(setupIntentId,
{ new SetupIntentCancelOptions { CancellationReason = "abandoned" });
var setupIntentId = await setupIntentCache.Get(provider.Id); await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id);
await stripeAdapter.SetupIntentCancel(setupIntentId, break;
new SetupIntentCancelOptions { CancellationReason = "abandoned" }); }
await setupIntentCache.Remove(provider.Id); case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
break; {
} await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): break;
{ }
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break;
}
}
} }
} }
} }
@@ -670,9 +605,10 @@ public class ProviderBillingService(
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); 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(); throw new BillingException();
} }
@@ -685,7 +621,9 @@ public class ProviderBillingService(
if (!providerPlan.IsConfigured()) 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(); throw new BillingException();
} }
@@ -698,23 +636,17 @@ public class ProviderBillingService(
}); });
} }
var requireProviderPaymentMethodDuringSetup = var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
var setupIntentId = await setupIntentCache.Get(provider.Id);
var setupIntent = !string.IsNullOrEmpty(setupIntentId) var setupIntent = !string.IsNullOrEmpty(setupIntentId)
? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions ? await stripeAdapter.SetupIntentGet(setupIntentId,
{ new SetupIntentGetOptions { Expand = ["payment_method"] })
Expand = ["payment_method"]
})
: null; : null;
var usePaymentMethod = var usePaymentMethod =
requireProviderPaymentMethodDuringSetup && !string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) ||
(!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true ||
customer.Metadata.ContainsKey(BraintreeCustomerIdKey) || setupIntent?.IsUnverifiedBankAccount() == true;
setupIntent.IsUnverifiedBankAccount());
int? trialPeriodDays = provider.Type switch int? trialPeriodDays = provider.Type switch
{ {
@@ -725,35 +657,20 @@ public class ProviderBillingService(
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
CollectionMethod = usePaymentMethod ? CollectionMethod =
StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice, usePaymentMethod
? CollectionMethod.ChargeAutomatically
: CollectionMethod.SendInvoice,
Customer = customer.Id, Customer = customer.Id,
DaysUntilDue = usePaymentMethod ? null : 30, DaysUntilDue = usePaymentMethod ? null : 30,
Items = subscriptionItemOptionsList, Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string> { { "providerId", provider.Id.ToString() } },
{
{ "providerId", provider.Id.ToString() }
},
OffSession = true, OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, ProrationBehavior = ProrationBehavior.CreateProrations,
TrialPeriodDays = trialPeriodDays 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 try
{ {
@@ -761,7 +678,7 @@ public class ProviderBillingService(
if (subscription is if (subscription is
{ {
Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing Status: SubscriptionStatus.Active or SubscriptionStatus.Trialing
}) })
{ {
return subscription; return subscription;
@@ -775,9 +692,11 @@ public class ProviderBillingService(
throw new BillingException(); 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)); subscriberService.UpdateTaxInformation(provider, taxInformation));
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically }); new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically });
} }
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
@@ -891,13 +810,9 @@ public class ProviderBillingService(
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
{ {
Items = [ Items =
new SubscriptionItemOptions [
{ new SubscriptionItemOptions { Id = item.Id, Price = priceId, Quantity = newlySubscribedSeats }
Id = item.Id,
Price = priceId,
Quantity = newlySubscribedSeats
}
] ]
}); });
@@ -920,7 +835,8 @@ public class ProviderBillingService(
var plan = await pricingClient.GetPlanOrThrow(planType); var plan = await pricingClient.GetPlanOrThrow(planType);
return providerOrganizations 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); .Sum(providerOrganization => providerOrganization.Seats ?? 0);
} }

View File

@@ -5,7 +5,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" /> <PackageReference Include="CsvHelper" Version="33.1.0" />
</ItemGroup> </ItemGroup>
</Project> </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.Commands.Porting.Interfaces;
using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories; 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.Exceptions;
using Bit.Core.Identity;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Commands.Projects.Interfaces; using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
using Bit.Core.SecretsManager.Entities; 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.Commands.ServiceAccounts.Interfaces;
using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
namespace Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts; namespace Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
@@ -10,15 +16,21 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand
private readonly IAccessPolicyRepository _accessPolicyRepository; private readonly IAccessPolicyRepository _accessPolicyRepository;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IEventService _eventService;
private readonly ICurrentContext _currentContext;
public CreateServiceAccountCommand( public CreateServiceAccountCommand(
IAccessPolicyRepository accessPolicyRepository, IAccessPolicyRepository accessPolicyRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IServiceAccountRepository serviceAccountRepository) IServiceAccountRepository serviceAccountRepository,
IEventService eventService,
ICurrentContext currentContext)
{ {
_accessPolicyRepository = accessPolicyRepository; _accessPolicyRepository = accessPolicyRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_serviceAccountRepository = serviceAccountRepository; _serviceAccountRepository = serviceAccountRepository;
_eventService = eventService;
_currentContext = currentContext;
} }
public async Task<ServiceAccount> CreateAsync(ServiceAccount serviceAccount, Guid userId) public async Task<ServiceAccount> CreateAsync(ServiceAccount serviceAccount, Guid userId)
@@ -35,6 +47,7 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand
Write = true, Write = true,
}; };
await _accessPolicyRepository.CreateManyAsync(new List<BaseAccessPolicy> { accessPolicy }); await _accessPolicyRepository.CreateManyAsync(new List<BaseAccessPolicy> { accessPolicy });
await _eventService.LogServiceAccountPeopleEventAsync(user.Id, accessPolicy, EventType.ServiceAccount_UserAdded, _currentContext.IdentityClientType);
return createdServiceAccount; 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.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.SecretsManager.Queries.Interfaces; using Bit.Core.SecretsManager.Queries.Interfaces;

View File

@@ -1,8 +1,10 @@
using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Commercial.Core.AdminConsole.Services; using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Billing.Providers.Queries;
using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Commercial.Core.Billing.Providers.Services;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Providers.Queries;
using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Providers.Services;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -17,5 +19,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>(); services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
services.AddTransient<IProviderBillingService, ProviderBillingService>(); services.AddTransient<IProviderBillingService, ProviderBillingService>();
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>(); 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(); using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope); 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( public async Task<IEnumerable<Core.SecretsManager.Entities.Secret>> GetManyByOrganizationIdAsync(
Guid organizationId, Guid userId, AccessClientType accessType) 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); 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(); using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope); var dbContext = GetDatabaseContext(scope);
var query = dbContext.Secret var query = dbContext.Secret
.Include(c => c.Projects) .Include(c => c.Projects)
.Where(c => c.OrganizationId == organizationId && c.DeletedDate == null) .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.Enums;
using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs; using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
using Bit.Core.Enums; 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.AdminConsole.Repositories;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; 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.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces; using Bit.Scim.Users.Interfaces;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
@@ -19,29 +21,28 @@ namespace Bit.Scim.Controllers.v2;
public class UsersController : Controller public class UsersController : Controller
{ {
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService;
private readonly IGetUsersListQuery _getUsersListQuery; private readonly IGetUsersListQuery _getUsersListQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IPatchUserCommand _patchUserCommand; private readonly IPatchUserCommand _patchUserCommand;
private readonly IPostUserCommand _postUserCommand; private readonly IPostUserCommand _postUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
public UsersController( public UsersController(IOrganizationUserRepository organizationUserRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IGetUsersListQuery getUsersListQuery, IGetUsersListQuery getUsersListQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IPatchUserCommand patchUserCommand, IPatchUserCommand patchUserCommand,
IPostUserCommand postUserCommand, IPostUserCommand postUserCommand,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand) IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_getUsersListQuery = getUsersListQuery; _getUsersListQuery = getUsersListQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_patchUserCommand = patchUserCommand; _patchUserCommand = patchUserCommand;
_postUserCommand = postUserCommand; _postUserCommand = postUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@@ -98,7 +99,7 @@ public class UsersController : Controller
} }
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked) 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 // Have to get full details object for response model

View File

@@ -1,7 +1,7 @@
############################################### ###############################################
# Build stage # # 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 # Docker buildx supplies the value for this arg
ARG TARGETPLATFORM ARG TARGETPLATFORM
@@ -9,11 +9,11 @@ ARG TARGETPLATFORM
# Determine proper runtime value for .NET # Determine proper runtime value for .NET
# We put the value in a file to be read by later layers. # We put the value in a file to be read by later layers.
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
RID=linux-x64 ; \ RID=linux-musl-x64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
RID=linux-arm64 ; \ RID=linux-musl-arm64 ; \
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
RID=linux-arm ; \ RID=linux-musl-arm ; \
fi \ fi \
&& echo "RID=$RID" > /tmp/rid.txt && echo "RID=$RID" > /tmp/rid.txt
@@ -37,21 +37,21 @@ RUN . /tmp/rid.txt && dotnet publish \
############################################### ###############################################
# App stage # # App stage #
############################################### ###############################################
FROM mcr.microsoft.com/dotnet/aspnet:8.0 FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21
ARG TARGETPLATFORM ARG TARGETPLATFORM
LABEL com.bitwarden.product="bitwarden" LABEL com.bitwarden.product="bitwarden"
ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:5000 ENV ASPNETCORE_URLS=http://+:5000
ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
EXPOSE 5000 EXPOSE 5000
RUN apt-get update \ RUN apk add --no-cache curl \
&& apt-get install -y --no-install-recommends \ krb5 \
gosu \ icu-libs \
curl \ shadow \
krb5-user \ && apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu
&& rm -rf /var/lib/apt/lists/*
# Copy app from the build stage # Copy app from the build stage
WORKDIR /app 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.Core.AdminConsole.Repositories;
using Bit.Scim.Groups.Interfaces; 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.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories; 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; 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 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; 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; 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; using Bit.Core.Utilities;
namespace Bit.Scim.Models; 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; 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; 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; 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.Models.Business;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
namespace Bit.Scim.Models; namespace Bit.Scim.Models;
@@ -44,7 +47,7 @@ public class ScimUserRequestModel : BaseScimUserModel
return new InviteOrganizationUsersRequest( return new InviteOrganizationUsersRequest(
invites: invites:
[ [
new Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models.OrganizationUserInvite( new OrganizationUserInviteCommandModel(
email: email, email: email,
externalId: ExternalIdForInvite() externalId: ExternalIdForInvite()
) )

View File

@@ -8,7 +8,7 @@ using Bit.Core.Utilities;
using Bit.Scim.Context; using Bit.Scim.Context;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;
using IdentityModel; using Duende.IdentityModel;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Stripe; 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.Core.Repositories;
using Bit.Scim.Users.Interfaces; 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; using Bit.Scim.Models;
namespace Bit.Scim.Users.Interfaces; 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.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Models; using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces; using Bit.Scim.Users.Interfaces;
@@ -11,20 +11,19 @@ namespace Bit.Scim.Users;
public class PatchUserCommand : IPatchUserCommand public class PatchUserCommand : IPatchUserCommand
{ {
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly ILogger<PatchUserCommand> _logger; private readonly ILogger<PatchUserCommand> _logger;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
public PatchUserCommand( public PatchUserCommand(IOrganizationUserRepository organizationUserRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand, IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
ILogger<PatchUserCommand> logger) ILogger<PatchUserCommand> logger,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand)
{ {
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_logger = logger; _logger = logger;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
} }
public async Task PatchUserAsync(Guid organizationId, Guid id, ScimPatchModel model) 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) else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)
{ {
await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM); await _revokeOrganizationUserCommand.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
return true; return true;
} }
return false; return false;

View File

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

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/bin/sh
# Setup # Setup
@@ -37,7 +37,7 @@ then
mkdir -p /etc/bitwarden/ca-certificates mkdir -p /etc/bitwarden/ca-certificates
chown -R $USERNAME:$GROUPNAME /etc/bitwarden 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 chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
fi fi
@@ -46,13 +46,13 @@ else
gosu_cmd="" gosu_cmd=""
fi 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 cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
$gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab $gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
fi fi
if [[ $globalSettings__selfHosted == "true" ]]; then if [ "$globalSettings__selfHosted" = "true" ]; then
if [[ -z $globalSettings__identityServer__certificateLocation ]]; then if [ -z "$globalSettings__identityServer__certificateLocation" ]; then
export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx
fi fi
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;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
@@ -19,10 +23,10 @@ using Bit.Core.Tokens;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Sso.Models; using Bit.Sso.Models;
using Bit.Sso.Utilities; using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer; using Duende.IdentityServer;
using Duende.IdentityServer.Services; using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores; using Duende.IdentityServer.Stores;
using IdentityModel;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -104,36 +108,32 @@ public class AccountController : Controller
// Validate domain_hint provided // Validate domain_hint provided
if (string.IsNullOrWhiteSpace(domainHint)) 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 // Validate organization exists from domain_hint
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint); var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
if (organization == null) if (organization is not { UseSso: true })
{ {
return InvalidJson("OrganizationNotFoundByIdentifierError"); _logger.LogError("Organization not configured to use SSO.");
} return InvalidJson("SsoInvalidIdentifierError");
if (!organization.UseSso)
{
return InvalidJson("SsoNotAllowedForOrganizationError");
} }
// Validate SsoConfig exists and is Enabled // Validate SsoConfig exists and is Enabled
var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint); var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint);
if (ssoConfig == null) if (ssoConfig is not { Enabled: true })
{ {
return InvalidJson("SsoConfigurationNotFoundForOrganizationError"); _logger.LogError("SsoConfig not enabled.");
} return InvalidJson("SsoInvalidIdentifierError");
if (!ssoConfig.Enabled)
{
return InvalidJson("SsoNotEnabledForOrganizationError");
} }
// Validate Authentication Scheme exists and is loaded (cache) // Validate Authentication Scheme exists and is loaded (cache)
var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString()); 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 // Run scheme validation
@@ -143,13 +143,8 @@ public class AccountController : Controller
} }
catch (Exception ex) catch (Exception ex)
{ {
var translatedException = _i18nService.GetLocalizedHtmlString(ex.Message); _logger.LogError(ex, "An error occurred while validating SSO dynamic scheme.");
var errorKey = "InvalidSchemeConfigurationError"; return InvalidJson("SsoInvalidIdentifierError");
if (!translatedException.ResourceNotFound)
{
errorKey = ex.Message;
}
return InvalidJson(errorKey, translatedException.ResourceNotFound ? ex : null);
} }
var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds); var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds);
@@ -159,7 +154,8 @@ public class AccountController : Controller
} }
catch (Exception ex) 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}"); var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
_logger.LogDebug("External claims: {@claims}", externalClaims); _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); 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) if (user == null)
{ {
// This might be where you might initiate a custom workflow for user registration // If we're manually linking to SSO, the user's external identifier will be passed as query string parameter.
// in this sample we don't show how that would be done, as our sample implementation
// simply auto-provisions new external user
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ? var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ?
result.Properties.Items["user_identifier"] : null; result.Properties.Items["user_identifier"] : null;
user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData); 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) if (user != null)
{ {
// This allows us to collect any additional claims or properties // 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)> private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims, SsoConfigurationData config)>
FindUserFromExternalProviderAsync(AuthenticateResult result) FindUserFromExternalProviderAsync(AuthenticateResult result)
{ {
@@ -399,6 +404,23 @@ public class AccountController : Controller
return (user, provider, providerUserId, claims, ssoConfigData); 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, private async Task<User> AutoProvisionUserAsync(string provider, string providerUserId,
IEnumerable<Claim> claims, string userIdentifier, SsoConfigurationData config) IEnumerable<Claim> claims, string userIdentifier, SsoConfigurationData config)
{ {
@@ -426,50 +448,15 @@ public class AccountController : Controller
} }
else else
{ {
var split = userIdentifier.Split(","); existingUser = await GetUserFromManualLinkingData(userIdentifier);
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"));
}
}
} }
OrganizationUser orgUser = null; // Try to find the OrganizationUser if it exists.
var organization = await _organizationRepository.GetByIdAsync(orgId); var (organization, orgUser) = await FindOrganizationUser(existingUser, email, orgId);
if (organization == null)
{
throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId));
}
// Try to find OrgUser via existing User Id (accepted/confirmed user) //----------------------------------------------------
if (existingUser != null) // Scenario 1: We've found the user in the User table
{ //----------------------------------------------------
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
if (existingUser != null) if (existingUser != null)
{ {
if (existingUser.UsesKeyConnector && if (existingUser.UsesKeyConnector &&
@@ -478,20 +465,22 @@ public class AccountController : Controller
throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector")); 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) if (orgUser == null)
{ {
// Org User is not created - no invite has been sent // Org User is not created - no invite has been sent
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess")); throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
} }
if (orgUser.Status == OrganizationUserStatusType.Invited) EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(),
{ allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]);
// 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()));
}
// 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); await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser);
return existingUser; return existingUser;
} }
@@ -534,7 +523,9 @@ public class AccountController : Controller
emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false; 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 var user = new User
{ {
Name = name, Name = name,
@@ -560,7 +551,11 @@ public class AccountController : Controller
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email); 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) if (orgUser == null)
{ {
orgUser = new OrganizationUser orgUser = new OrganizationUser
@@ -572,18 +567,107 @@ public class AccountController : Controller
}; };
await _organizationUserRepository.CreateAsync(orgUser); 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 else
{ {
orgUser.UserId = user.Id; orgUser.UserId = user.Id;
await _organizationUserRepository.ReplaceAsync(orgUser); 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); await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser);
return user; 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) private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
{ {
Response.StatusCode = ex == null ? 400 : 500; 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 Bit.Sso.Models;
using Duende.IdentityServer.Services; using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;

View File

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

View File

@@ -10,7 +10,7 @@
<!-- This is a transitive dependency to Sustainsys.Saml2.AspNetCore2 --> <!-- This is a transitive dependency to Sustainsys.Saml2.AspNetCore2 -->
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" /> <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>
<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; using System.Text.RegularExpressions;
namespace Bit.Sso.Utilities; 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.Entities;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Sso.Models; using Bit.Sso.Models;
using Bit.Sso.Utilities; using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer; using Duende.IdentityServer;
using Duende.IdentityServer.Infrastructure; using Duende.IdentityServer.Infrastructure;
using IdentityModel;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -413,7 +417,7 @@ public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider
SPOptions = spOptions, SPOptions = spOptions,
SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme, SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme,
SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme, SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme,
CookieManager = new IdentityServer.DistributedCacheCookieManager(), CookieManager = new DistributedCacheCookieManager(),
}; };
options.IdentityProviders.Add(idp); 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; using Microsoft.Extensions.Options;
namespace Bit.Sso.Utilities; 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; using Microsoft.IdentityModel.Protocols.OpenIdConnect;
namespace Bit.Sso.Utilities; 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.Text;
using System.Xml; using System.Xml;
using Sustainsys.Saml2; 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.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
using Bit.Commercial.Core.AdminConsole.Providers; using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
@@ -263,7 +262,8 @@ public class RemoveOrganizationFromProviderCommandTests
org => org =>
org.BillingEmail == "a@example.com" && org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "subscription_id" && 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) await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization); .DeleteAsync(providerOrganization);
@@ -331,9 +331,6 @@ public class RemoveOrganizationFromProviderCommandTests
Id = "subscription_id" Id = "subscription_id"
}); });
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge).Returns(true);
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization); await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options => await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
@@ -354,7 +351,8 @@ public class RemoveOrganizationFromProviderCommandTests
org => org =>
org.BillingEmail == "a@example.com" && org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "subscription_id" && 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) await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization); .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.AdminConsole.Services;
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture; using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business.Provider; using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums; 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.Pricing;
using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Context; using Bit.Core.Context;
@@ -41,7 +41,7 @@ public class ProviderServiceTests
public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider) public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
{ {
var exception = await Assert.ThrowsAsync<BadRequestException>( 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); Assert.Contains("Invalid owner.", exception.Message);
} }
@@ -53,85 +53,12 @@ public class ProviderServiceTests
userService.GetUserByIdAsync(user.Id).Returns(user); userService.GetUserByIdAsync(user.Id).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>( 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); Assert.Contains("Invalid token.", exception.Message);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException( public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress,
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,
[ProviderUser] ProviderUser providerUser, [ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider) SutProvider<ProviderService> sutProvider)
{ {
@@ -151,7 +78,7 @@ public class ProviderServiceTests
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>(); var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
var customer = new Customer { Id = "customer_id" }; 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" }; var subscription = new Subscription { Id = "subscription_id" };
providerBillingService.SetupSubscription(provider).Returns(subscription); 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)}"); 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>( await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
p => p =>
@@ -188,6 +115,262 @@ public class ProviderServiceTests
await sutProvider.Sut.UpdateAsync(provider); 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] [Theory, BitAutoData]
public async Task InviteUserAsync_ProviderIdIsInvalid_Throws(ProviderUserInvite<string> invite, SutProvider<ProviderService> sutProvider) 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) => private static SubscriptionUpdateOptions SubscriptionUpdateRequest(string expectedPlanId, Subscription subscriptionItem) =>
new() new()
{ {
Items = new List<Stripe.SubscriptionItemOptions> Items = new List<SubscriptionItemOptions>
{ {
new() { Id = subscriptionItem.Id, Price = expectedPlanId }, 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.Pricing;
using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,6 +53,7 @@ services:
- ./.data/postgres/log:/var/log/postgresql - ./.data/postgres/log:/var/log/postgresql
profiles: profiles:
- postgres - postgres
- ef
mysql: mysql:
image: mysql:8.0 image: mysql:8.0
@@ -69,6 +70,7 @@ services:
- mysql_dev_data:/var/lib/mysql - mysql_dev_data:/var/lib/mysql
profiles: profiles:
- mysql - mysql
- ef
mariadb: mariadb:
image: mariadb:10 image: mariadb:10
@@ -76,13 +78,13 @@ services:
- 4306:3306 - 4306:3306
environment: environment:
MARIADB_USER: maria MARIADB_USER: maria
MARIADB_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_DATABASE: vault_dev MARIADB_DATABASE: vault_dev
MARIADB_RANDOM_ROOT_PASSWORD: "true" MARIADB_RANDOM_ROOT_PASSWORD: "true"
volumes: volumes:
- mariadb_dev_data:/var/lib/mysql - mariadb_dev_data:/var/lib/mysql
profiles: profiles:
- mariadb - mariadb
- ef
idp: idp:
image: kenchan0130/simplesamlphp:1.19.8 image: kenchan0130/simplesamlphp:1.19.8
@@ -99,7 +101,7 @@ services:
- idp - idp
rabbitmq: rabbitmq:
image: rabbitmq:4.1.0-management image: rabbitmq:4.1.3-management
container_name: rabbitmq container_name: rabbitmq
ports: ports:
- "5672:5672" - "5672:5672"
@@ -153,5 +155,6 @@ volumes:
mssql_dev_data: mssql_dev_data:
postgres_dev_data: postgres_dev_data:
mysql_dev_data: mysql_dev_data:
mariadb_dev_data:
rabbitmq_data: rabbitmq_data:
redis_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), @($mysql, "MySQL", "MySqlMigrations", "mySql", 2),
# MariaDB shares the MySQL connection string in the server config so they are mutually exclusive in that context. # 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. # 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) { if (!$item[0] -and !$all) {
continue continue

View File

@@ -33,6 +33,8 @@
"id": "<your Installation Id>", "id": "<your Installation Id>",
"key": "<your Installation Key>" "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-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] [Benchmark]
public Client? TryGetValue() public Client? TryGetValue()
{ {
return _store.ApiClients.TryGetValue(ClientId, out var client) return _store.Clients.TryGetValue(ClientId, out var client)
? client ? client
: null; : null;
} }

View File

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

View File

@@ -9,12 +9,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL;
const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH; const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
export const options = { export const options = {
ext: {
loadimpact: {
projectID: 3639465,
name: "Config",
},
},
scenarios: { scenarios: {
constant_load: { constant_load: {
executor: "constant-arrival-rate", 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; const AUTH_CLIENT_SECRET = __ENV.AUTH_CLIENT_SECRET;
export const options = { export const options = {
ext: {
loadimpact: {
projectID: 3639465,
name: "Groups",
},
},
scenarios: { scenarios: {
constant_load: { constant_load: {
executor: "constant-arrival-rate", 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; const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
export const options = { export const options = {
ext: {
loadimpact: {
projectID: 3639465,
name: "Login",
},
},
scenarios: { scenarios: {
constant_load: { constant_load: {
executor: "constant-arrival-rate", 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; const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH;
export const options = { export const options = {
ext: {
loadimpact: {
projectID: 3639465,
name: "Sync",
},
},
scenarios: { scenarios: {
constant_load: { constant_load: {
executor: "constant-arrival-rate", 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.AdminConsole.Models;
using Bit.Admin.Enums; using Bit.Admin.Enums;
using Bit.Admin.Services; using Bit.Admin.Services;
@@ -6,6 +9,7 @@ using Bit.Admin.Utilities;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
@@ -29,7 +33,6 @@ namespace Bit.Admin.AdminConsole.Controllers;
[Authorize] [Authorize]
public class OrganizationsController : Controller public class OrganizationsController : Controller
{ {
private readonly IOrganizationService _organizationService;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationConnectionRepository _organizationConnectionRepository; private readonly IOrganizationConnectionRepository _organizationConnectionRepository;
@@ -52,9 +55,9 @@ public class OrganizationsController : Controller
private readonly IProviderBillingService _providerBillingService; private readonly IProviderBillingService _providerBillingService;
private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand; private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
public OrganizationsController( public OrganizationsController(
IOrganizationService organizationService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IOrganizationConnectionRepository organizationConnectionRepository, IOrganizationConnectionRepository organizationConnectionRepository,
@@ -76,9 +79,9 @@ public class OrganizationsController : Controller
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand, IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand, IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand,
IPricingClient pricingClient) IPricingClient pricingClient,
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
{ {
_organizationService = organizationService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_organizationConnectionRepository = organizationConnectionRepository; _organizationConnectionRepository = organizationConnectionRepository;
@@ -101,6 +104,7 @@ public class OrganizationsController : Controller
_providerBillingService = providerBillingService; _providerBillingService = providerBillingService;
_organizationInitiateDeleteCommand = organizationInitiateDeleteCommand; _organizationInitiateDeleteCommand = organizationInitiateDeleteCommand;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
} }
[RequirePermission(Permission.Org_List_View)] [RequirePermission(Permission.Org_List_View)]
@@ -392,7 +396,7 @@ public class OrganizationsController : Controller
var organizationUsers = await _organizationUserRepository.GetManyByOrganizationAsync(id, OrganizationUserType.Owner); var organizationUsers = await _organizationUserRepository.GetManyByOrganizationAsync(id, OrganizationUserType.Owner);
foreach (var organizationUser in organizationUsers) foreach (var organizationUser in organizationUsers)
{ {
await _organizationService.ResendInviteAsync(id, null, organizationUser.Id, true); await _resendOrganizationInviteCommand.ResendInviteAsync(id, null, organizationUser.Id, true);
} }
return Json(null); 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 System.Net;
using Bit.Admin.AdminConsole.Models; using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums; using Bit.Admin.Enums;
using Bit.Admin.Services;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; 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.Models;
using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@@ -34,26 +38,26 @@ namespace Bit.Admin.AdminConsole.Controllers;
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public class ProvidersController : Controller public class ProvidersController : Controller
{ {
private readonly string _stripeUrl;
private readonly string _braintreeMerchantUrl;
private readonly string _braintreeMerchantId;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IResellerClientOrganizationSignUpCommand _resellerClientOrganizationSignUpCommand; private readonly IResellerClientOrganizationSignUpCommand _resellerClientOrganizationSignUpCommand;
private readonly IProviderRepository _providerRepository; private readonly IProviderRepository _providerRepository;
private readonly IProviderUserRepository _providerUserRepository; private readonly IProviderUserRepository _providerUserRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IProviderService _providerService;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly IProviderService _providerService;
private readonly ICreateProviderCommand _createProviderCommand; private readonly ICreateProviderCommand _createProviderCommand;
private readonly IFeatureService _featureService;
private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderPlanRepository _providerPlanRepository;
private readonly IProviderBillingService _providerBillingService; private readonly IProviderBillingService _providerBillingService;
private readonly IPricingClient _pricingClient; private readonly IPricingClient _pricingClient;
private readonly IStripeAdapter _stripeAdapter; private readonly IStripeAdapter _stripeAdapter;
private readonly string _stripeUrl; private readonly IAccessControlService _accessControlService;
private readonly string _braintreeMerchantUrl; private readonly ISubscriberService _subscriberService;
private readonly string _braintreeMerchantId;
public ProvidersController( public ProvidersController(IOrganizationRepository organizationRepository,
IOrganizationRepository organizationRepository,
IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand, IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand,
IProviderRepository providerRepository, IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
@@ -62,12 +66,13 @@ public class ProvidersController : Controller
GlobalSettings globalSettings, GlobalSettings globalSettings,
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
ICreateProviderCommand createProviderCommand, ICreateProviderCommand createProviderCommand,
IFeatureService featureService,
IProviderPlanRepository providerPlanRepository, IProviderPlanRepository providerPlanRepository,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
IWebHostEnvironment webHostEnvironment, IWebHostEnvironment webHostEnvironment,
IPricingClient pricingClient, IPricingClient pricingClient,
IStripeAdapter stripeAdapter) IStripeAdapter stripeAdapter,
IAccessControlService accessControlService,
ISubscriberService subscriberService)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand; _resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
@@ -78,14 +83,15 @@ public class ProvidersController : Controller
_globalSettings = globalSettings; _globalSettings = globalSettings;
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_createProviderCommand = createProviderCommand; _createProviderCommand = createProviderCommand;
_featureService = featureService;
_providerPlanRepository = providerPlanRepository; _providerPlanRepository = providerPlanRepository;
_providerBillingService = providerBillingService; _providerBillingService = providerBillingService;
_pricingClient = pricingClient; _pricingClient = pricingClient;
_stripeAdapter = stripeAdapter; _stripeAdapter = stripeAdapter;
_accessControlService = accessControlService;
_stripeUrl = webHostEnvironment.GetStripeUrl(); _stripeUrl = webHostEnvironment.GetStripeUrl();
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId; _braintreeMerchantId = globalSettings.Braintree.MerchantId;
_subscriberService = subscriberService;
} }
[RequirePermission(Permission.Provider_List_View)] [RequirePermission(Permission.Provider_List_View)]
@@ -288,9 +294,31 @@ public class ProvidersController : Controller
return View(oldModel); return View(oldModel);
} }
var originalProviderStatus = provider.Enabled;
model.ToProvider(provider); 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); await _applicationCacheService.UpsertProviderAbilityAsync(provider);
if (!provider.IsBillable()) if (!provider.IsBillable())
@@ -311,21 +339,17 @@ public class ProvidersController : Controller
]); ]);
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
if (_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically)) var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId);
if (model.PayByInvoice != customer.ApprovedToPayByInvoice())
{ {
var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId); var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0";
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
if (model.PayByInvoice != customer.ApprovedToPayByInvoice())
{ {
var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0"; Metadata = new Dictionary<string, string>
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{ {
Metadata = new Dictionary<string, string> [StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice
{ }
[StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice });
}
});
}
} }
break; break;
case ProviderType.BusinessUnit: case ProviderType.BusinessUnit:
@@ -370,10 +394,7 @@ public class ProvidersController : Controller
} }
var providerPlans = await _providerPlanRepository.GetByProviderId(id); var providerPlans = await _providerPlanRepository.GetByProviderId(id);
var payByInvoice = ((await _subscriberService.GetCustomer(provider))?.ApprovedToPayByInvoice() ?? false);
var payByInvoice =
_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) &&
(await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice();
return new ProviderEditModel( return new ProviderEditModel(
provider, users, providerOrganizations, 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.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums; 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.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities; 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.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities; 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 System.Net;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;

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